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