Skip to main content

lash_core/runtime/
lifecycle.rs

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