Skip to main content

systemprompt_api/services/server/
runner.rs

1//! Server run loop: MCP orchestrator wiring and lifecycle supervision.
2//!
3//! The startup phases a node runs follow its `server.role`
4//! ([`super::routes::role::lifecycle_plan`]): a gateway node spawns no MCP
5//! servers or agents and runs no scheduler; a runner node does neither and
6//! instead starts every extension's [`systemprompt_traits::RunnerService`].
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use anyhow::{Context, Result};
12use std::sync::Arc;
13use systemprompt_identifiers::Actor;
14use systemprompt_runtime::AppContext;
15use systemprompt_scheduler::services::SchedulerHandle;
16use systemprompt_traits::{
17    Dependencies, JobContext, OwnedTask, Phase, StartupEvent, StartupEventExt, StartupEventSender,
18};
19
20use super::lifecycle::{
21    initialize_scheduler, reconcile_agents, reconcile_system_services, start_event_bridge,
22    start_registry_heartbeat, start_schema_watch, start_secrets_refresh,
23};
24use super::routes::role::LifecyclePlan;
25
26pub async fn run_server(
27    ctx: AppContext,
28    events: Option<StartupEventSender>,
29    early: super::startup::EarlyServer,
30) -> Result<()> {
31    let start_time = std::time::Instant::now();
32
33    let instance_claim = super::replica_identity::claim(&ctx)
34        .await
35        .context("replica identity")?;
36    tracing::info!(instance_id = %instance_claim.instance_id(), "replica identity claimed");
37
38    let plan = super::routes::role::lifecycle_plan(ctx.config().role);
39    tracing::info!(role = %ctx.config().role, "node role");
40
41    let scheduler_handle = start_role_phases(&ctx, plan, events.as_ref()).await?;
42
43    if let Some(ref tx) = events {
44        tx.phase_started(Phase::ApiServer);
45    }
46    let router = crate::services::server::setup_api_server(&ctx, events.as_ref())?;
47    start_accounting_recovery(&ctx).await?;
48    let addr = ctx.server_address();
49
50    early.activate(router);
51    let metrics_listener = start_metrics_listener(&ctx, plan.scheduler).await?;
52    super::readiness::signal_ready();
53
54    if let Some(ref tx) = events {
55        tx.phase_completed(Phase::ApiServer);
56    }
57
58    if let Some(ref tx) = events {
59        tx.startup_complete(start_time.elapsed(), format!("http://{}", addr), vec![]);
60    }
61
62    systemprompt_logging::set_startup_mode(false);
63
64    serve_until_shutdown(
65        &ctx,
66        early,
67        metrics_listener,
68        scheduler_handle,
69        instance_claim,
70    )
71    .await
72}
73
74async fn start_role_phases(
75    ctx: &AppContext,
76    plan: LifecyclePlan,
77    events: Option<&StartupEventSender>,
78) -> Result<Option<SchedulerHandle>> {
79    start_event_bridge(ctx);
80    start_registry_heartbeat(ctx);
81    start_secrets_refresh(ctx);
82    start_schema_watch(ctx);
83    if plan.reconcile_mcp {
84        let mcp_orchestrator = create_mcp_orchestrator(ctx)?;
85        reconcile_system_services(ctx, &mcp_orchestrator, events).await?;
86    }
87    if plan.reconcile_agents {
88        run_agents_phase(ctx, events).await?;
89    }
90    let scheduler_handle = if plan.scheduler {
91        run_scheduler_phase(ctx, events).await?
92    } else {
93        None
94    };
95    if plan.runner_services {
96        start_runner_services(ctx);
97    }
98    Ok(scheduler_handle)
99}
100
101async fn serve_until_shutdown(
102    ctx: &AppContext,
103    early: super::startup::EarlyServer,
104    metrics_listener: Option<OwnedTask<()>>,
105    scheduler_handle: Option<SchedulerHandle>,
106    instance_claim: systemprompt_database::InstanceClaim,
107) -> Result<()> {
108    let timings = ctx.config().shutdown;
109    tracing::info!(
110        pre_stop_delay_secs = timings.pre_stop_delay_secs,
111        drain_grace_secs = timings.drain_grace_secs,
112        forced_exit_grace_secs = timings.forced_exit_grace_secs,
113        total_budget_secs = timings.total_budget().as_secs(),
114        "Shutdown budget; the orchestrator's termination grace must cover the total"
115    );
116    let restart = ctx.shutdown_request().clone();
117    let serve_result = super::shutdown::join_within_drain_grace(
118        early.join(),
119        &restart,
120        &timings,
121        ctx.background_tasks().stream_drain(),
122    )
123    .await;
124
125    let forced_exit = super::shutdown::arm_forced_exit(restart, timings);
126    if let Some(listener) = metrics_listener
127        && listener.abort_and_join().await.is_some()
128    {
129        tracing::debug!("Metrics listener had already stopped on the shutdown signal");
130    }
131    super::shutdown::drain(ctx, scheduler_handle).await;
132    instance_claim.release().await;
133    forced_exit.abort();
134
135    serve_result
136}
137
138fn start_runner_services(ctx: &AppContext) {
139    let shared = Arc::new(ctx.clone());
140    let mut started = 0usize;
141    for extension in ctx.extension_registry().extensions() {
142        for service in extension.runner_services() {
143            let name = service.name();
144            let actor = Actor::job(ctx.system_admin().id().clone(), name);
145            let dependencies = Dependencies::new()
146                .with(Arc::clone(ctx.app_paths_arc()))
147                .with(Arc::clone(ctx.db_pool()))
148                .with(Arc::clone(&shared));
149            let job_ctx = JobContext::new(actor, dependencies);
150            ctx.background_tasks()
151                .spawn_cancellable(name, move |shutdown| async move {
152                    service.run(job_ctx, shutdown).await;
153                });
154            tracing::info!(
155                extension = extension.id(),
156                service = name,
157                "runner service started"
158            );
159            started += 1;
160        }
161    }
162    tracing::info!(started, "runner services started");
163}
164
165async fn run_agents_phase(ctx: &AppContext, events: Option<&StartupEventSender>) -> Result<()> {
166    if let Some(tx) = events {
167        tx.phase_started(Phase::Agents);
168    }
169    match reconcile_agents(ctx, events).await {
170        Ok(started_count) => {
171            if let Some(tx) = events {
172                send_startup_event(
173                    tx,
174                    StartupEvent::AgentReconciliationComplete {
175                        running: started_count,
176                        total: started_count,
177                    },
178                );
179                tx.phase_completed(Phase::Agents);
180            }
181            Ok(())
182        },
183        Err(e) => Err(fail_phase(
184            events,
185            Phase::Agents,
186            format!("Agent reconciliation failed: {e}"),
187            e,
188        )),
189    }
190}
191
192async fn run_scheduler_phase(
193    ctx: &AppContext,
194    events: Option<&StartupEventSender>,
195) -> Result<Option<SchedulerHandle>> {
196    if let Some(tx) = events {
197        tx.phase_started(Phase::Scheduler);
198    }
199    match initialize_scheduler(ctx, events).await {
200        Ok(handle) => {
201            if let Some(tx) = events {
202                tx.phase_completed(Phase::Scheduler);
203            }
204            Ok(handle)
205        },
206        Err(e) => Err(fail_phase(
207            events,
208            Phase::Scheduler,
209            format!("Scheduler initialization failed: {e}"),
210            e,
211        )),
212    }
213}
214
215fn fail_phase(
216    events: Option<&StartupEventSender>,
217    phase: Phase,
218    message: String,
219    error: anyhow::Error,
220) -> anyhow::Error {
221    if let Some(tx) = events {
222        tx.phase_failed(phase, error.to_string());
223        send_startup_event(
224            tx,
225            StartupEvent::Error {
226                message,
227                fatal: true,
228            },
229        );
230    }
231    error
232}
233
234fn send_startup_event(tx: &StartupEventSender, event: StartupEvent) {
235    if tx.unbounded_send(event).is_err() {
236        tracing::debug!("Startup event receiver dropped");
237    }
238}
239
240fn create_mcp_orchestrator(
241    ctx: &AppContext,
242) -> Result<Arc<systemprompt_mcp::services::McpOrchestrator>> {
243    use systemprompt_mcp::services::McpOrchestrator;
244    let manager = McpOrchestrator::new(
245        (**ctx.service_repository()).clone(),
246        Arc::clone(ctx.app_paths_arc()),
247        ctx.mcp_registry().clone(),
248    )?;
249    Ok(Arc::new(manager))
250}
251
252async fn start_metrics_listener(
253    ctx: &AppContext,
254    runs_scheduler: bool,
255) -> Result<Option<OwnedTask<()>>> {
256    let Some(port) = ctx.config().metrics_port else {
257        return Ok(None);
258    };
259    let handle = super::metrics::install_recorder(
260        &ctx.config().instance_id,
261        &ctx.extension_registry().all_metrics(),
262    )?;
263    super::pool_metrics::spawn_sampler(ctx);
264    super::ops_metrics::spawn_samplers(ctx, runs_scheduler);
265    let addr = std::net::SocketAddr::new(ctx.config().host.parse()?, port);
266    Ok(Some(
267        super::metrics::serve_metrics_listener(addr, handle).await?,
268    ))
269}
270
271async fn start_accounting_recovery(ctx: &AppContext) -> Result<()> {
272    let settlement = crate::routes::gateway::gateway_repositories(ctx)?.settlement();
273    let settled = systemprompt_gateway::audit::journal::recover(&settlement).await?;
274    if settled > 0 {
275        tracing::info!(settled, "Gateway accounting receipts recovered at startup");
276    }
277    systemprompt_gateway::audit::journal::spawn_recovery(settlement, ctx.background_tasks());
278    Ok(())
279}