Skip to main content

lash_core/runtime/
lifecycle.rs

1use super::*;
2
3impl LashRuntime {
4    /// Override the owner id used for durable session execution leases.
5    ///
6    /// Normal embedded runtimes use a fresh UUID so concurrent opens of the
7    /// same session exclude each other. Durable orchestrators may set a stable
8    /// owner for one serialized logical workflow so a replay on another host can
9    /// re-enter its own live lease after a crash.
10    pub fn set_runtime_scope_id(&mut self, runtime_scope_id: impl Into<String>) {
11        self.runtime_scope_id = Arc::<str>::from(runtime_scope_id.into());
12    }
13
14    pub fn unregister_plugin_session(&self) -> Result<(), crate::PluginError> {
15        if let Some(session) = self.session.as_ref() {
16            session
17                .plugins()
18                .host()
19                .unregister_session(&self.state.session_id)?;
20        }
21        Ok(())
22    }
23
24    pub(super) async fn from_host_state(
25        policy: SessionPolicy,
26        host: RuntimeHost,
27        services: RuntimeServices,
28        mut state: RuntimeSessionState,
29    ) -> Result<Self, SessionError> {
30        if state.session_id.is_empty() {
31            state.session_id = uuid::Uuid::new_v4().to_string();
32        }
33        // Defaulted state (e.g. `RuntimeSessionState::default()` used
34        // by fresh-session constructors) carries an empty policy.
35        // Fill it in from the caller's policy so tests and hosts that
36        // pass a real policy alongside default state don't trip the explicit
37        // model-spec guard below.
38        let state_policy_was_unconfigured = state.policy.recorded_provider_id().is_empty()
39            && state.policy.model.id.trim().is_empty();
40        if state_policy_was_unconfigured {
41            state.policy = policy.clone();
42        }
43        state.ensure_agent_frame_initialized();
44        let state_policy = state.policy.clone();
45        if let Some(frame) = state.current_agent_frame_mut()
46            && frame.assignment.policy.recorded_provider_id().is_empty()
47            && frame.assignment.policy.model.id.trim().is_empty()
48        {
49            frame.assignment.policy = state_policy;
50        }
51        state.policy = state.effective_policy().clone();
52        state.protocol_turn_options = state.effective_protocol_turn_options().clone();
53        normalize_session_graph(&mut state);
54        let policy = state.effective_policy().clone();
55        if policy.model.id.trim().is_empty() {
56            return Err(SessionError::Protocol(
57                "session policy missing model spec; hosts must supply explicit model metadata"
58                    .to_string(),
59            ));
60        }
61        let mut host = host;
62        // When a persistent backend is wired in, wrap the attachment
63        // store so every `put` records a write-ahead intent row first.
64        // Crashes between put and the next turn commit then surface as
65        // uncommitted manifest rows that GC can reconcile. Ephemeral
66        // (no-store) runtimes use the inner store directly — there's
67        // nothing to reconcile against.
68        if let Some(store) = services.store.clone() {
69            let manifest: Arc<dyn crate::AttachmentManifest> =
70                Arc::new(crate::attachments::PersistenceManifestAdapter(store));
71            let scoped: Arc<dyn crate::AttachmentStore> =
72                Arc::new(crate::SessionScopedAttachmentStore::new(
73                    Arc::clone(&host.core.durability.attachment_store),
74                    manifest,
75                    state.session_id.clone(),
76                ));
77            host.core.durability.attachment_store = scoped;
78        }
79        let services = services
80            .with_attachment_store(Arc::clone(&host.core.durability.attachment_store))
81            .with_process_env_store(Arc::clone(&host.core.durability.process_env_store))
82            .with_clock(Arc::clone(&host.core.clock));
83        let mut session = Session::new(services.clone(), &state.session_id).await?;
84        if let Some(tool_state) = state.tool_state_snapshot.clone() {
85            // Cold rebuild restores the exact persisted tool catalog, adopting
86            // the snapshot's generation. `apply_state` (a delta-apply that
87            // requires `snapshot.generation == base` and bumps) would reject a
88            // session whose surface reached generation ≥ 2 onto a fresh base-1
89            // registry — the worker-rebuild / restart divergence. `restore_state`
90            // adopts the snapshot's generation wholesale, so any generation
91            // rebuilds.
92            let report = session
93                .plugins()
94                .tool_registry()
95                .restore_state(tool_state)
96                .map_err(|err| SessionError::Protocol(err.to_string()))?;
97            if !report.orphaned.is_empty() {
98                tracing::warn!(
99                    session_id = %state.session_id,
100                    orphaned = ?report.orphaned,
101                    "session restored with orphaned tools: no registered source \
102                     resolves them; they are Off until their source returns"
103                );
104            }
105        }
106        session.refresh_tool_catalog().await?;
107        if let Some(snapshot) = state.plugin_snapshot.clone() {
108            session
109                .plugins()
110                .restore(&snapshot)
111                .map_err(|err| SessionError::Protocol(err.to_string()))?;
112        }
113        let protocol_session = Arc::clone(session.plugins().protocol_session());
114        let session_id = state.session_id.clone();
115        protocol_session
116            .restore_session(
117                crate::plugin::ProtocolSessionContext::new(&mut session, &session_id),
118                &state,
119            )
120            .await?;
121        state.discard_runtime_snapshots();
122        session
123            .plugins()
124            .emit_runtime_event(crate::PluginLifecycleEvent::SessionRestored(
125                crate::SessionReadView::from_persisted_state(&state),
126            ))
127            .await;
128        let protocol_turn_options = state.protocol_turn_options.clone();
129        Ok(Self {
130            session: Some(session),
131            policy,
132            host,
133            services,
134            state,
135            runtime_scope_id: Arc::<str>::from(uuid::Uuid::new_v4().to_string()),
136            managed_sessions: Arc::new(Mutex::new(HashMap::new())),
137            managed_turns: Arc::new(Mutex::new(HashMap::new())),
138            protocol_turn_options,
139            shared_token_ledger: Arc::new(std::sync::Mutex::new(Vec::new())),
140            process_sync_needed: Arc::new(AtomicBool::new(false)),
141            turn_phase_probe: None,
142            residency: Residency::default(),
143        })
144    }
145
146    /// Build a runtime for an embedded host with no background worker support.
147    pub async fn from_embedded_state(
148        policy: SessionPolicy,
149        host: EmbeddedRuntimeHost,
150        services: RuntimeServices,
151        state: RuntimeSessionState,
152    ) -> Result<Self, SessionError> {
153        Self::from_host_state(policy, host.into(), services, state).await
154    }
155
156    /// Build a runtime for a host that supports background plugin work.
157    pub async fn from_background_state(
158        policy: SessionPolicy,
159        host: ProcessRuntimeHost,
160        services: RuntimeServices,
161        state: RuntimeSessionState,
162    ) -> Result<Self, SessionError> {
163        Self::from_host_state(policy, host.into(), services, state).await
164    }
165
166    /// Build a runtime for an embedded host with persistent store support.
167    pub async fn from_persistent_embedded_state(
168        policy: SessionPolicy,
169        host: EmbeddedRuntimeHost,
170        services: PersistentRuntimeServices,
171        state: RuntimeSessionState,
172    ) -> Result<Self, SessionError> {
173        Self::from_host_state(policy, host.into(), services.into_runtime_services(), state).await
174    }
175
176    /// Build a runtime for a background-capable host with persistent store support.
177    pub async fn from_persistent_background_state(
178        policy: SessionPolicy,
179        host: ProcessRuntimeHost,
180        services: PersistentRuntimeServices,
181        state: RuntimeSessionState,
182    ) -> Result<Self, SessionError> {
183        Self::from_host_state(policy, host.into(), services.into_runtime_services(), state).await
184    }
185
186    /// Assemble a runtime from already-resolved parts: the single place that maps
187    /// `(store, process_registry)` to the right host/services constructor, applies
188    /// residency, and stamps it onto the runtime.
189    ///
190    /// Every construction path — the live open (`from_environment`), the worker
191    /// rebuild (`EmbeddedRuntimeBuilder::build`), and child-session
192    /// materialization — routes through here so the store/registry wiring and
193    /// residency cannot drift between them. That drift previously shipped: the
194    /// worker rebuild silently kept the full graph and skipped the persisted
195    /// tool-catalog restore that the live path applied.
196    pub(crate) async fn assemble_runtime(
197        policy: SessionPolicy,
198        embedded_host: EmbeddedRuntimeHost,
199        plugin_session: Arc<crate::PluginSession>,
200        store: Option<Arc<dyn crate::store::RuntimePersistence>>,
201        process_registry: Option<Arc<dyn ProcessRegistry>>,
202        mut state: RuntimeSessionState,
203        residency: Residency,
204    ) -> Result<Self, SessionError> {
205        // ActivePathOnly without a store is a data-loss footgun: trimming drops
206        // orphans from RAM with nowhere to reload them from.
207        if matches!(residency, Residency::ActivePathOnly) && store.is_none() {
208            return Err(SessionError::Protocol(
209                "Residency::ActivePathOnly requires a persistent store — \
210                 without one, trimmed orphans are irrecoverable"
211                    .to_string(),
212            ));
213        }
214        // Heal FIRST (against the full resident set), then trim to the residency.
215        // `from_host_state` normalizes again, which is safe on a trimmed graph.
216        normalize_session_graph(&mut state);
217        apply_residency_on_load(&mut state, residency);
218        let mut runtime = match (store, process_registry) {
219            (Some(store), Some(registry)) => {
220                let host = ProcessRuntimeHost::new(embedded_host, registry);
221                let services = PersistentRuntimeServices::new(plugin_session, store);
222                Self::from_persistent_background_state(policy, host, services, state).await?
223            }
224            (Some(store), None) => {
225                let services = PersistentRuntimeServices::new(plugin_session, store);
226                Self::from_persistent_embedded_state(policy, embedded_host, services, state).await?
227            }
228            (None, Some(registry)) => {
229                let host = ProcessRuntimeHost::new(embedded_host, registry);
230                let services = RuntimeServices::new(plugin_session);
231                Self::from_background_state(policy, host, services, state).await?
232            }
233            (None, None) => {
234                let services = RuntimeServices::new(plugin_session);
235                Self::from_embedded_state(policy, embedded_host, services, state).await?
236            }
237        };
238        runtime.residency = residency;
239        Ok(runtime)
240    }
241
242    /// Embedder-preferred constructor: build a `LashRuntime` from a
243    /// shared `RuntimeEnvironment`.
244    ///
245    /// Everything expensive (plugin factories, HTTP client pool, prompt
246    /// template, path resolver) lives on the environment and is
247    /// reused across every runtime the embedder builds. This call is
248    /// O(plugin-session-registration + state-hydration), not
249    /// O(full-infrastructure-init).
250    ///
251    /// * `env` — the shared environment. `env.plugin_host` must be set.
252    /// * `policy` — per-session policy (model, provider, autonomy, turn limits).
253    /// * `state` — persisted session state (empty for a fresh session).
254    /// * `store` — per-session store. `None` builds an embedded runtime
255    ///   with no persistence; `Some` builds a persistent
256    ///   background-capable runtime.
257    pub async fn from_environment(
258        env: &RuntimeEnvironment,
259        policy: SessionPolicy,
260        state: RuntimeSessionState,
261        store: Option<Arc<dyn crate::store::RuntimePersistence>>,
262    ) -> Result<Self, SessionError> {
263        let plugin_host = env.plugin_host.as_ref().ok_or_else(|| {
264            SessionError::Protocol(
265                "RuntimeEnvironment.plugin_host is required for from_environment".to_string(),
266            )
267        })?;
268        let plugin_session = plugin_host
269            .build_session(state.session_id.as_str(), state.plugin_snapshot.as_ref())
270            .map_err(|err| SessionError::Protocol(err.to_string()))?;
271        let mut embedded = EmbeddedRuntimeHost::new(env.core.clone());
272        if let Some(factory) = env.session_store_factory.as_ref() {
273            embedded = embedded.with_session_store_factory(Arc::clone(factory));
274        }
275        if let Some(store) = env.trigger_store.as_ref() {
276            embedded = embedded.with_trigger_store(Arc::clone(store));
277        }
278        let mut runtime = Self::assemble_runtime(
279            policy,
280            embedded,
281            plugin_session,
282            store,
283            env.process_registry.as_ref().cloned(),
284            state,
285            env.residency,
286        )
287        .await?;
288        // Thread the host-owned work drivers onto this session's host so
289        // process starts and queued turns can drive ready work directly.
290        runtime.host.process_work_driver = env.process_work_driver.clone();
291        runtime.host.queued_work_driver = env.queued_work_driver.clone();
292        Ok(runtime)
293    }
294
295    /// Persist any dirty state and drop the runtime, returning a lightweight
296    /// handle the embedder can cache and resume later via
297    /// [`LashRuntime::resume`]. This is the webserver-embedder parking
298    /// primitive: the handle holds only the session id, policy, and store
299    /// reference — no graph nodes, no plugin session, no HTTP client.
300    pub async fn park(mut self) -> Result<ParkedSession, SessionError> {
301        let store = self.services.store.clone().ok_or_else(|| {
302            SessionError::Protocol(
303                "park() requires a persistent runtime (store is not set)".to_string(),
304            )
305        })?;
306        let session_id = self.state.session_id.clone();
307        let policy = self.policy.clone();
308        // Flush any dirty resident state to the store before dropping.
309        let commit = crate::store::RuntimeCommit::persisted_state(&self.state, &[]);
310        let result = commit_runtime_state_with_fresh_session_execution_lease(
311            Arc::clone(&store),
312            commit,
313            "runtime.park",
314            Arc::clone(&self.host.core.clock),
315        )
316        .await
317        .map_err(|err| SessionError::Protocol(format!("failed to persist runtime state: {err}")))?;
318        self.state.apply_persisted_commit_result(result);
319        // Drain pending tombstones if any. Under KeepHistory this is a
320        // no-op (tombstones never get added). Under DropOrphans, a future
321        // orphan-trim path would populate the set for Phase 10's vacuum()
322        // design.
323        Ok(ParkedSession {
324            session_id,
325            store,
326            policy,
327        })
328    }
329
330    /// Resume a previously parked session against a shared environment.
331    /// Loads only the active-path graph when
332    /// `env.residency == ActivePathOnly`; under `KeepAll`
333    /// loads the full graph (current behavior).
334    pub async fn resume(
335        parked: ParkedSession,
336        env: &RuntimeEnvironment,
337    ) -> Result<Self, SessionError> {
338        // Under ActivePathOnly, skip the full-graph load: fetch head
339        // metadata + the active-path chain only. Durable impls can
340        // ActivePathOnly is an exact store capability. Stores that do
341        // not support it must return UnsupportedReadScope; resume does
342        // not fall back to a full graph load.
343        let loaded = match env.residency {
344            Residency::KeepAll => {
345                crate::store::load_persisted_session_state(parked.store.as_ref()).await
346            }
347            Residency::ActivePathOnly => {
348                crate::store::load_persisted_session_state_active_path(parked.store.as_ref(), None)
349                    .await
350            }
351        }
352        .map_err(|err| SessionError::Protocol(format!("failed to load runtime state: {err}")))?;
353        let state = loaded.unwrap_or_else(|| RuntimeSessionState {
354            session_id: parked.session_id.clone(),
355            policy: parked.policy.clone(),
356            ..RuntimeSessionState::default()
357        });
358        Self::from_environment(env, parked.policy, state, Some(parked.store)).await
359    }
360
361    /// Opt-in async read for historic (non-active-path) nodes under
362    /// `Residency::ActivePathOnly`. Plugins that walk the full graph
363    /// call this instead of `session_graph().find_node()` so missing
364    /// nodes surface as `Ok(None)` rather than silently missing.
365    pub async fn get_historic_node(
366        &self,
367        node_id: &str,
368    ) -> Result<Option<crate::SessionNodeRecord>, SessionError> {
369        if let Some(node) = self.state.session_graph.find_node(node_id) {
370            return Ok(Some(node.clone()));
371        }
372        let store = self.services.store.clone().ok_or_else(|| {
373            SessionError::Protocol("get_historic_node() requires a persistent runtime".to_string())
374        })?;
375        store
376            .load_node(node_id)
377            .await
378            .map_err(|err| SessionError::Protocol(format!("failed to load historic node: {err}")))
379    }
380
381    /// Store-resident node IDs that are NOT reachable from the current
382    /// leaf — i.e. orphans eligible for tombstoning. lash owns RAM; the
383    /// host owns disk lifecycle, so this is a primitive the host calls
384    /// on its own schedule (e.g. every N turns, or off-peak).
385    ///
386    /// Typical autonomous-agent loop:
387    ///
388    /// ```ignore
389    /// let orphans = runtime.orphaned_node_ids().await?;
390    /// if !orphans.is_empty() {
391    ///     store.tombstone_nodes(&orphans).await;
392    /// }
393    /// // And less often:
394    /// store.vacuum().await;
395    /// ```
396    pub async fn orphaned_node_ids(&self) -> Result<Vec<String>, SessionError> {
397        let store = self.services.store.clone().ok_or_else(|| {
398            SessionError::Protocol("orphaned_node_ids() requires a persistent runtime".to_string())
399        })?;
400        let Some(read) = store
401            .load_session(crate::store::SessionReadScope::FullGraph)
402            .await
403            .map_err(|err| SessionError::Protocol(format!("failed to load full graph: {err}")))?
404        else {
405            return Ok(Vec::new());
406        };
407        let active: std::collections::HashSet<&str> = read
408            .graph
409            .active_path_nodes()
410            .iter()
411            .map(|node| node.node_id.as_str())
412            .collect();
413        Ok(read
414            .graph
415            .nodes
416            .iter()
417            .filter(|node| !active.contains(node.node_id.as_str()))
418            .map(|node| node.node_id.clone())
419            .collect())
420    }
421}