Skip to main content

lash_core/runtime/
lifecycle.rs

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