systemprompt_api/services/server/
runner.rs1use 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
251async 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}