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,
23};
24
25pub async fn run_server(
26    ctx: AppContext,
27    events: Option<StartupEventSender>,
28    early: super::startup::EarlyServer,
29) -> Result<()> {
30    let start_time = std::time::Instant::now();
31
32    let instance_claim = ctx
33        .service_repository()
34        .claim_instance()
35        .await
36        .context("replica identity")?;
37    tracing::info!(instance_id = %instance_claim.instance_id(), "replica identity claimed");
38
39    let plan = super::routes::role::lifecycle_plan(ctx.config().role);
40    tracing::info!(role = %ctx.config().role, "node role");
41
42    start_event_bridge(&ctx);
43    start_registry_heartbeat(&ctx);
44    if plan.reconcile_mcp {
45        let mcp_orchestrator = create_mcp_orchestrator(&ctx)?;
46        reconcile_system_services(&ctx, &mcp_orchestrator, events.as_ref()).await?;
47    }
48    if plan.reconcile_agents {
49        run_agents_phase(&ctx, events.as_ref()).await?;
50    }
51    let scheduler_handle = if plan.scheduler {
52        run_scheduler_phase(&ctx, events.as_ref()).await?
53    } else {
54        None
55    };
56    if plan.runner_services {
57        start_runner_services(&ctx);
58    }
59
60    if let Some(ref tx) = events {
61        tx.phase_started(Phase::ApiServer);
62    }
63    let router = crate::services::server::setup_api_server(&ctx, events.as_ref())?;
64    start_accounting_recovery(&ctx).await?;
65    let addr = ctx.server_address();
66
67    early.activate(router);
68    let metrics_listener = start_metrics_listener(&ctx).await?;
69    super::readiness::signal_ready();
70
71    if let Some(ref tx) = events {
72        tx.phase_completed(Phase::ApiServer);
73    }
74
75    if let Some(ref tx) = events {
76        tx.startup_complete(start_time.elapsed(), format!("http://{}", addr), vec![]);
77    }
78
79    systemprompt_logging::set_startup_mode(false);
80
81    let restart = ctx.shutdown_request().clone();
82    let serve_result = super::shutdown::join_within_drain_grace(early.join(), &restart).await;
83
84    let forced_exit = super::shutdown::arm_forced_exit(restart);
85    if let Some(listener) = metrics_listener
86        && listener.abort_and_join().await.is_some()
87    {
88        tracing::debug!("Metrics listener had already stopped on the shutdown signal");
89    }
90    super::shutdown::drain(&ctx, scheduler_handle).await;
91    instance_claim.release().await;
92    forced_exit.abort();
93
94    serve_result
95}
96
97fn start_runner_services(ctx: &AppContext) {
98    let shared = Arc::new(ctx.clone());
99    let mut started = 0usize;
100    for extension in ctx.extension_registry().extensions() {
101        for service in extension.runner_services() {
102            let name = service.name();
103            let actor = Actor::job(ctx.system_admin().id().clone(), name);
104            let dependencies = Dependencies::new()
105                .with(Arc::clone(ctx.app_paths_arc()))
106                .with(Arc::clone(ctx.db_pool()))
107                .with(Arc::clone(&shared));
108            let job_ctx = JobContext::new(actor, dependencies);
109            ctx.background_tasks()
110                .spawn_cancellable(name, move |shutdown| async move {
111                    service.run(job_ctx, shutdown).await;
112                });
113            tracing::info!(
114                extension = extension.id(),
115                service = name,
116                "runner service started"
117            );
118            started += 1;
119        }
120    }
121    tracing::info!(started, "runner services started");
122}
123
124async fn run_agents_phase(ctx: &AppContext, events: Option<&StartupEventSender>) -> Result<()> {
125    if let Some(tx) = events {
126        tx.phase_started(Phase::Agents);
127    }
128    match reconcile_agents(ctx, events).await {
129        Ok(started_count) => {
130            if let Some(tx) = events {
131                send_startup_event(
132                    tx,
133                    StartupEvent::AgentReconciliationComplete {
134                        running: started_count,
135                        total: started_count,
136                    },
137                );
138                tx.phase_completed(Phase::Agents);
139            }
140            Ok(())
141        },
142        Err(e) => Err(fail_phase(
143            events,
144            Phase::Agents,
145            format!("Agent reconciliation failed: {e}"),
146            e,
147        )),
148    }
149}
150
151async fn run_scheduler_phase(
152    ctx: &AppContext,
153    events: Option<&StartupEventSender>,
154) -> Result<Option<SchedulerHandle>> {
155    if let Some(tx) = events {
156        tx.phase_started(Phase::Scheduler);
157    }
158    match initialize_scheduler(ctx, events).await {
159        Ok(handle) => {
160            if let Some(tx) = events {
161                tx.phase_completed(Phase::Scheduler);
162            }
163            Ok(handle)
164        },
165        Err(e) => Err(fail_phase(
166            events,
167            Phase::Scheduler,
168            format!("Scheduler initialization failed: {e}"),
169            e,
170        )),
171    }
172}
173
174fn fail_phase(
175    events: Option<&StartupEventSender>,
176    phase: Phase,
177    message: String,
178    error: anyhow::Error,
179) -> anyhow::Error {
180    if let Some(tx) = events {
181        tx.phase_failed(phase, error.to_string());
182        send_startup_event(
183            tx,
184            StartupEvent::Error {
185                message,
186                fatal: true,
187            },
188        );
189    }
190    error
191}
192
193fn send_startup_event(tx: &StartupEventSender, event: StartupEvent) {
194    if tx.unbounded_send(event).is_err() {
195        tracing::debug!("Startup event receiver dropped");
196    }
197}
198
199fn create_mcp_orchestrator(
200    ctx: &AppContext,
201) -> Result<Arc<systemprompt_mcp::services::McpOrchestrator>> {
202    use systemprompt_mcp::services::McpOrchestrator;
203    let manager = McpOrchestrator::new(
204        (**ctx.service_repository()).clone(),
205        Arc::clone(ctx.app_paths_arc()),
206        ctx.mcp_registry().clone(),
207    )?;
208    Ok(Arc::new(manager))
209}
210
211async fn start_metrics_listener(ctx: &AppContext) -> Result<Option<OwnedTask<()>>> {
212    let Some(port) = ctx.config().metrics_port else {
213        return Ok(None);
214    };
215    let handle = super::metrics::install_recorder(&ctx.config().instance_id)?;
216    super::pool_metrics::spawn_sampler(ctx);
217    let addr = std::net::SocketAddr::new(ctx.config().host.parse()?, port);
218    Ok(Some(
219        super::metrics::serve_metrics_listener(addr, handle).await?,
220    ))
221}
222
223async fn start_accounting_recovery(ctx: &AppContext) -> Result<()> {
224    let settlement = crate::routes::gateway::gateway_repositories(ctx)?.settlement();
225    let settled = systemprompt_gateway::audit::journal::recover(&settlement).await?;
226    if settled > 0 {
227        tracing::info!(settled, "Gateway accounting receipts recovered at startup");
228    }
229    systemprompt_gateway::audit::journal::spawn_recovery(settlement, ctx.background_tasks());
230    Ok(())
231}