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};
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 = claim_replica_identity(&ctx)
33        .await
34        .context("replica identity")?;
35    tracing::info!(instance_id = %instance_claim.instance_id(), "replica identity claimed");
36
37    let plan = super::routes::role::lifecycle_plan(ctx.config().role);
38    tracing::info!(role = %ctx.config().role, "node role");
39
40    start_event_bridge(&ctx);
41    start_registry_heartbeat(&ctx);
42    start_secrets_refresh(&ctx);
43    start_schema_watch(&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, plan.scheduler).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 timings = ctx.config().shutdown;
82    tracing::info!(
83        pre_stop_delay_secs = timings.pre_stop_delay_secs,
84        drain_grace_secs = timings.drain_grace_secs,
85        forced_exit_grace_secs = timings.forced_exit_grace_secs,
86        total_budget_secs = timings.total_budget().as_secs(),
87        "Shutdown budget; the orchestrator's termination grace must cover the total"
88    );
89    let restart = ctx.shutdown_request().clone();
90    let serve_result = super::shutdown::join_within_drain_grace(
91        early.join(),
92        &restart,
93        &timings,
94        ctx.background_tasks().stream_drain(),
95    )
96    .await;
97
98    let forced_exit = super::shutdown::arm_forced_exit(restart, timings);
99    if let Some(listener) = metrics_listener
100        && listener.abort_and_join().await.is_some()
101    {
102        tracing::debug!("Metrics listener had already stopped on the shutdown signal");
103    }
104    super::shutdown::drain(&ctx, scheduler_handle).await;
105    instance_claim.release().await;
106    forced_exit.abort();
107
108    serve_result
109}
110
111fn start_runner_services(ctx: &AppContext) {
112    let shared = Arc::new(ctx.clone());
113    let mut started = 0usize;
114    for extension in ctx.extension_registry().extensions() {
115        for service in extension.runner_services() {
116            let name = service.name();
117            let actor = Actor::job(ctx.system_admin().id().clone(), name);
118            let dependencies = Dependencies::new()
119                .with(Arc::clone(ctx.app_paths_arc()))
120                .with(Arc::clone(ctx.db_pool()))
121                .with(Arc::clone(&shared));
122            let job_ctx = JobContext::new(actor, dependencies);
123            ctx.background_tasks()
124                .spawn_cancellable(name, move |shutdown| async move {
125                    service.run(job_ctx, shutdown).await;
126                });
127            tracing::info!(
128                extension = extension.id(),
129                service = name,
130                "runner service started"
131            );
132            started += 1;
133        }
134    }
135    tracing::info!(started, "runner services started");
136}
137
138async fn run_agents_phase(ctx: &AppContext, events: Option<&StartupEventSender>) -> Result<()> {
139    if let Some(tx) = events {
140        tx.phase_started(Phase::Agents);
141    }
142    match reconcile_agents(ctx, events).await {
143        Ok(started_count) => {
144            if let Some(tx) = events {
145                send_startup_event(
146                    tx,
147                    StartupEvent::AgentReconciliationComplete {
148                        running: started_count,
149                        total: started_count,
150                    },
151                );
152                tx.phase_completed(Phase::Agents);
153            }
154            Ok(())
155        },
156        Err(e) => Err(fail_phase(
157            events,
158            Phase::Agents,
159            format!("Agent reconciliation failed: {e}"),
160            e,
161        )),
162    }
163}
164
165async fn run_scheduler_phase(
166    ctx: &AppContext,
167    events: Option<&StartupEventSender>,
168) -> Result<Option<SchedulerHandle>> {
169    if let Some(tx) = events {
170        tx.phase_started(Phase::Scheduler);
171    }
172    match initialize_scheduler(ctx, events).await {
173        Ok(handle) => {
174            if let Some(tx) = events {
175                tx.phase_completed(Phase::Scheduler);
176            }
177            Ok(handle)
178        },
179        Err(e) => Err(fail_phase(
180            events,
181            Phase::Scheduler,
182            format!("Scheduler initialization failed: {e}"),
183            e,
184        )),
185    }
186}
187
188fn fail_phase(
189    events: Option<&StartupEventSender>,
190    phase: Phase,
191    message: String,
192    error: anyhow::Error,
193) -> anyhow::Error {
194    if let Some(tx) = events {
195        tx.phase_failed(phase, error.to_string());
196        send_startup_event(
197            tx,
198            StartupEvent::Error {
199                message,
200                fatal: true,
201            },
202        );
203    }
204    error
205}
206
207fn send_startup_event(tx: &StartupEventSender, event: StartupEvent) {
208    if tx.unbounded_send(event).is_err() {
209        tracing::debug!("Startup event receiver dropped");
210    }
211}
212
213fn create_mcp_orchestrator(
214    ctx: &AppContext,
215) -> Result<Arc<systemprompt_mcp::services::McpOrchestrator>> {
216    use systemprompt_mcp::services::McpOrchestrator;
217    let manager = McpOrchestrator::new(
218        (**ctx.service_repository()).clone(),
219        Arc::clone(ctx.app_paths_arc()),
220        ctx.mcp_registry().clone(),
221    )?;
222    Ok(Arc::new(manager))
223}
224
225async fn start_metrics_listener(
226    ctx: &AppContext,
227    runs_scheduler: bool,
228) -> Result<Option<OwnedTask<()>>> {
229    let Some(port) = ctx.config().metrics_port else {
230        return Ok(None);
231    };
232    let handle = super::metrics::install_recorder(&ctx.config().instance_id)?;
233    super::pool_metrics::spawn_sampler(ctx);
234    super::ops_metrics::spawn_samplers(ctx, runs_scheduler);
235    let addr = std::net::SocketAddr::new(ctx.config().host.parse()?, port);
236    Ok(Some(
237        super::metrics::serve_metrics_listener(addr, handle).await?,
238    ))
239}
240
241async fn start_accounting_recovery(ctx: &AppContext) -> Result<()> {
242    let settlement = crate::routes::gateway::gateway_repositories(ctx)?.settlement();
243    let settled = systemprompt_gateway::audit::journal::recover(&settlement).await?;
244    if settled > 0 {
245        tracing::info!(settled, "Gateway accounting receipts recovered at startup");
246    }
247    systemprompt_gateway::audit::journal::spawn_recovery(settlement, ctx.background_tasks());
248    Ok(())
249}
250
251// Why: a redeployed host reuses its HOSTNAME, and the session of the process
252// it replaced holds the claim until the server's keepalive probes declare it
253// dead; waiting past that window tells a vanished holder from a live one.
254async fn claim_replica_identity(
255    ctx: &AppContext,
256) -> Result<systemprompt_database::InstanceClaim, systemprompt_database::InstanceClaimError> {
257    let window = std::time::Duration::from_secs(systemprompt_database::CLAIM_DEAD_PEER_SECS * 3);
258    let deadline = std::time::Instant::now() + window;
259    let mut backoff = std::time::Duration::from_secs(1);
260    loop {
261        match ctx.service_repository().claim_instance().await {
262            Err(systemprompt_database::InstanceClaimError::Claimed { instance_id })
263                if std::time::Instant::now() + backoff < deadline =>
264            {
265                tracing::warn!(
266                    instance_id = %instance_id,
267                    retry_in_ms = backoff.as_millis(),
268                    "replica identity held by another session; waiting for a dead holder to be reaped"
269                );
270                tokio::time::sleep(backoff).await;
271                backoff = (backoff * 2).min(std::time::Duration::from_secs(8));
272            },
273            outcome => return outcome,
274        }
275    }
276}