Skip to main content

lean_ctx/core/
execution_lifecycle.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Canonical execution lifecycle and subordinate Decision Spine state.
3
4use std::{
5    any::Any,
6    cell::RefCell,
7    collections::HashMap,
8    fmt,
9    sync::{Arc, Condvar, Mutex, OnceLock},
10    time::Instant,
11};
12
13tokio::task_local! {
14    static DEFERRED_HEATMAP: RefCell<Vec<HeatmapObservation>>;
15}
16
17thread_local! {
18    static SYNC_DEFERRED_HEATMAP: RefCell<Option<Vec<HeatmapObservation>>> = const {
19        RefCell::new(None)
20    };
21}
22
23use lean_ctx_protocol::{AcceptanceState, AcceptedOutcomeV1, TaskEnvelopeV1};
24
25use crate::core::{
26    outcome::{
27        contracts::{OutcomeContractV1, TaskClass},
28        evaluator::OutcomeEvaluator,
29        signals::{LocalSignalAdapters, OutcomeSignal as CanonicalSignal},
30    },
31    task_spine::TaskSpine,
32    triage::{TriageEngine, profile::TaskProfileLocal},
33    value_gate::{
34        ExecutionCost, OutcomeSignal, TaskOutcome, ValueAssessment, ValueGate,
35        cost_tracker::calculate_cost,
36    },
37};
38
39/// Ordered responsibilities owned by every execution lifecycle.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum LifecycleStage {
42    IdentifyCaller,
43    ResolveWorkspace,
44    ResolveEntitlements,
45    ApplySecurityBoundaries,
46    GatherContextStrategy,
47    AskAutopilot,
48    DispatchPrimitive,
49    ReversiblePostProcess,
50    RecordContextIr,
51    RecordLedger,
52    RecordEvidence,
53    RecordOutcome,
54    UpdateLearning,
55    UpdateHeatmap,
56    UpdateBreakEven,
57    Checkpoint,
58    ScheduleTelemetry,
59    FlushState,
60}
61
62pub const LIFECYCLE_STAGE_ORDER: [LifecycleStage; 18] = [
63    LifecycleStage::IdentifyCaller,
64    LifecycleStage::ResolveWorkspace,
65    LifecycleStage::ResolveEntitlements,
66    LifecycleStage::ApplySecurityBoundaries,
67    LifecycleStage::GatherContextStrategy,
68    LifecycleStage::AskAutopilot,
69    LifecycleStage::DispatchPrimitive,
70    LifecycleStage::ReversiblePostProcess,
71    LifecycleStage::RecordContextIr,
72    LifecycleStage::RecordLedger,
73    LifecycleStage::RecordEvidence,
74    LifecycleStage::RecordOutcome,
75    LifecycleStage::UpdateLearning,
76    LifecycleStage::UpdateHeatmap,
77    LifecycleStage::UpdateBreakEven,
78    LifecycleStage::Checkpoint,
79    LifecycleStage::ScheduleTelemetry,
80    LifecycleStage::FlushState,
81];
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum StageDisposition {
85    Applied,
86    Skipped(&'static str),
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct StageExecution {
91    pub stage: LifecycleStage,
92    pub disposition: StageDisposition,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub enum ToolSurface {
97    Mcp,
98    Cli,
99    Daemon,
100    Hook,
101    Proxy,
102}
103
104#[derive(Debug, Clone)]
105pub struct ToolRequest {
106    pub tool_name: String,
107    pub query: Option<String>,
108    pub session_id: String,
109    pub agent_id: String,
110    pub surface: ToolSurface,
111    pub idempotency_key: Option<String>,
112}
113
114#[derive(Debug, Clone, Default)]
115pub struct RuntimeContext {
116    pub client_name: Option<String>,
117    pub project_root: Option<String>,
118}
119
120#[derive(Debug, Clone, Copy, Default)]
121pub struct ProductEntitlements {
122    pub autopilot: bool,
123    pub personalized_learning: bool,
124}
125
126#[derive(Debug, Clone)]
127pub struct CompletionObservation {
128    pub input_tokens: u64,
129    pub output_tokens: u64,
130    pub model: String,
131    pub provider: String,
132    pub success: bool,
133    /// Evidence-bearing legacy signals. An empty set never implies acceptance.
134    pub outcome_signals: Vec<OutcomeSignal>,
135    pub proxy_economics: Option<ProxyEconomicsObservation>,
136    pub heatmap: Option<HeatmapObservation>,
137}
138
139#[derive(Debug, Clone)]
140pub struct ProxyEconomicsObservation {
141    pub tokens_pruned: usize,
142    pub original_tokens: usize,
143    pub task_class: String,
144}
145
146#[derive(Debug, Clone)]
147pub struct HeatmapObservation {
148    pub path: String,
149    pub original_tokens: usize,
150    pub saved_tokens: usize,
151}
152
153/// Defer heatmap writes to stage 14 when called inside an async lifecycle run.
154pub fn record_heatmap_access(path: &str, original_tokens: usize, saved_tokens: usize) {
155    let observation = HeatmapObservation {
156        path: path.to_owned(),
157        original_tokens,
158        saved_tokens,
159    };
160    if DEFERRED_HEATMAP
161        .try_with(|pending| pending.borrow_mut().push(observation.clone()))
162        .is_ok()
163    {
164        return;
165    }
166    let deferred = SYNC_DEFERRED_HEATMAP.with(|pending| {
167        let mut pending = pending.borrow_mut();
168        pending.as_mut().is_some_and(|items| {
169            items.push(observation);
170            true
171        })
172    });
173    if !deferred {
174        crate::core::heatmap::record_file_access(path, original_tokens, saved_tokens);
175    }
176}
177
178pub fn begin_sync_heatmap_capture() {
179    SYNC_DEFERRED_HEATMAP.with(|pending| *pending.borrow_mut() = Some(Vec::new()));
180}
181
182fn take_deferred_heatmap() -> Vec<HeatmapObservation> {
183    DEFERRED_HEATMAP
184        .try_with(|pending| std::mem::take(&mut *pending.borrow_mut()))
185        .unwrap_or_else(|_| {
186            SYNC_DEFERRED_HEATMAP.with(|pending| pending.borrow_mut().take().unwrap_or_default())
187        })
188}
189
190/// Owned cancellation state, finalized synchronously by the canonical guard.
191pub struct CancellationCompletion {
192    pub observation: CompletionObservation,
193    pub ledger: StageDisposition,
194}
195
196pub type CancellationHandler = Box<dyn FnOnce() -> CancellationCompletion + Send>;
197
198#[async_trait::async_trait]
199pub trait ExecutionDriver: Send {
200    type Primitive: Send;
201    type Processed: Send;
202    type Output: Clone + Send + Sync + 'static;
203    type Error: Clone + Send + Sync + 'static;
204
205    /// Retain only observations needed if the in-flight driver future is dropped.
206    /// The handler and normal ledger stage must share an exactly-once owner.
207    fn cancellation_handler(&self) -> Option<CancellationHandler> {
208        None
209    }
210
211    async fn identify_caller(
212        &mut self,
213        _context: &TaskContext,
214    ) -> Result<StageDisposition, Self::Error> {
215        Ok(StageDisposition::Skipped("caller identity unavailable"))
216    }
217
218    async fn resolve_workspace(
219        &mut self,
220        _context: &TaskContext,
221    ) -> Result<StageDisposition, Self::Error> {
222        Ok(StageDisposition::Skipped("workspace unavailable"))
223    }
224
225    async fn resolve_entitlements(
226        &mut self,
227        _context: &TaskContext,
228    ) -> Result<StageDisposition, Self::Error> {
229        Ok(StageDisposition::Skipped("no product entitlement source"))
230    }
231
232    async fn apply_security_boundaries(
233        &mut self,
234        _context: &TaskContext,
235    ) -> Result<StageDisposition, Self::Error> {
236        Ok(StageDisposition::Skipped("no surface security adapter"))
237    }
238
239    async fn gather_context_strategy(
240        &mut self,
241        _context: &TaskContext,
242    ) -> Result<StageDisposition, Self::Error> {
243        Ok(StageDisposition::Skipped("no context strategy"))
244    }
245
246    async fn ask_autopilot(
247        &mut self,
248        context: &TaskContext,
249    ) -> Result<StageDisposition, Self::Error> {
250        Ok(if context.entitlements.autopilot {
251            // Entitlement is permission, not evidence that a planner ran.
252            StageDisposition::Skipped("no canonical autopilot decision")
253        } else {
254            StageDisposition::Skipped("not entitled")
255        })
256    }
257
258    /// Re-run mutable admission policy before returning an idempotent cached result.
259    async fn validate_cached_replay(
260        &mut self,
261        _context: &TaskContext,
262    ) -> Result<Option<Self::Output>, Self::Error> {
263        Ok(None)
264    }
265
266    async fn dispatch_primitive(
267        &mut self,
268        context: &TaskContext,
269    ) -> Result<(Self::Primitive, StageDisposition), Self::Error>;
270
271    async fn reversible_post_process(
272        &mut self,
273        context: &TaskContext,
274        primitive: Self::Primitive,
275    ) -> Result<(Self::Processed, StageDisposition), Self::Error>;
276
277    async fn record_context_ir(
278        &mut self,
279        context: &TaskContext,
280        processed: &Self::Processed,
281    ) -> Result<StageDisposition, Self::Error>;
282
283    async fn record_ledger(
284        &mut self,
285        context: &TaskContext,
286        processed: &Self::Processed,
287    ) -> Result<StageDisposition, Self::Error>;
288
289    async fn checkpoint(
290        &mut self,
291        _context: &TaskContext,
292        _output: &mut Self::Output,
293    ) -> Result<StageDisposition, Self::Error> {
294        Ok(StageDisposition::Skipped("checkpoint not configured"))
295    }
296
297    fn output_from_processed(&mut self, processed: Self::Processed) -> Self::Output;
298
299    fn observe(
300        &self,
301        result: &Result<Self::Output, LifecycleRunError<Self::Error>>,
302    ) -> CompletionObservation;
303}
304
305impl CompletionObservation {
306    pub fn tool_result(input_tokens: u64, output_tokens: u64, model: &str, success: bool) -> Self {
307        Self {
308            input_tokens,
309            output_tokens,
310            model: model.to_owned(),
311            provider: "mcp".to_owned(),
312            success,
313            // Transport status does not establish compiler or task-quality evidence.
314            outcome_signals: Vec::new(),
315            proxy_economics: None,
316            heatmap: None,
317        }
318    }
319}
320
321#[derive(Debug, Clone)]
322pub struct LifecycleOutcome {
323    pub accepted_outcome: AcceptedOutcomeV1,
324    pub assessment: Option<ValueAssessment>,
325}
326
327#[derive(Debug, Clone)]
328enum RunState {
329    Open,
330    Completing,
331    Completed(Box<LifecycleOutcome>),
332}
333
334#[cfg(test)]
335type FinalizationHook = Arc<dyn Fn(LifecycleStage, Option<&LifecycleOutcome>) + Send + Sync>;
336
337#[cfg(test)]
338fn test_finalization_point(
339    context: &TaskContext,
340    stage: LifecycleStage,
341    outcome: Option<&LifecycleOutcome>,
342) {
343    let hook = context
344        .shared
345        .finalization_hook
346        .lock()
347        .unwrap_or_else(std::sync::PoisonError::into_inner)
348        .clone();
349    if let Some(hook) = hook {
350        hook(stage, outcome);
351    }
352}
353
354struct SharedRun {
355    state: Mutex<RunState>,
356    completed: Condvar,
357    dispatch: Mutex<DispatchState>,
358    dispatch_completed: tokio::sync::Notify,
359    stages: Mutex<Vec<StageExecution>>,
360    computed_outcome: OnceLock<LifecycleOutcome>,
361    telemetry_started: std::sync::atomic::AtomicBool,
362    flush_started: std::sync::atomic::AtomicBool,
363    #[cfg(test)]
364    flush_hook: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
365    #[cfg(test)]
366    finalization_hook: Mutex<Option<FinalizationHook>>,
367    autopilot: OnceLock<Arc<crate::core::context_kernel::bridge::runtime::PreparedKernelContext>>,
368    #[cfg(any(test, feature = "test-support"))]
369    autopilot_planning_attempts: std::sync::atomic::AtomicUsize,
370}
371
372enum DispatchState {
373    Idle,
374    Running,
375    Finished(Arc<dyn Any + Send + Sync>),
376    Aborted(Box<LifecycleOutcome>),
377}
378
379#[derive(Debug, Clone)]
380pub enum LifecycleRunError<E> {
381    Dispatch(E),
382    Aborted(Box<LifecycleOutcome>),
383    /// A cached execution belongs to a different typed driver contract.
384    ReplayTypeMismatch,
385}
386
387impl fmt::Debug for SharedRun {
388    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
389        formatter.debug_struct("SharedRun").finish_non_exhaustive()
390    }
391}
392
393impl Default for SharedRun {
394    fn default() -> Self {
395        Self {
396            state: Mutex::new(RunState::Open),
397            completed: Condvar::new(),
398            dispatch: Mutex::new(DispatchState::Idle),
399            dispatch_completed: tokio::sync::Notify::new(),
400            stages: Mutex::new(Vec::new()),
401            computed_outcome: OnceLock::new(),
402            telemetry_started: std::sync::atomic::AtomicBool::new(false),
403            flush_started: std::sync::atomic::AtomicBool::new(false),
404            #[cfg(test)]
405            flush_hook: Mutex::new(None),
406            #[cfg(test)]
407            finalization_hook: Mutex::new(None),
408            autopilot: OnceLock::new(),
409            #[cfg(any(test, feature = "test-support"))]
410            autopilot_planning_attempts: std::sync::atomic::AtomicUsize::new(0),
411        }
412    }
413}
414
415/// Handle shared by surface adapters for one idempotent execution.
416#[derive(Debug, Clone)]
417pub struct TaskContext {
418    pub task_id: String,
419    pub session_id: String,
420    pub triage_class: String,
421    pub profile_intent: String,
422    pub profile_complexity: String,
423    pub filtered_lines: usize,
424    pub start_time: Instant,
425    pub envelope: TaskEnvelopeV1,
426    pub profile: TaskProfileLocal,
427    pub surface: ToolSurface,
428    pub runtime: RuntimeContext,
429    pub entitlements: ProductEntitlements,
430    planning_query: Option<String>,
431    shared: Arc<SharedRun>,
432}
433
434#[path = "execution_lifecycle_state.rs"]
435mod task_state;
436
437#[derive(Debug)]
438struct DecisionSpine {
439    triage: TriageEngine,
440    value_gate: ValueGate,
441    task_profiles: Mutex<HashMap<String, TaskProfileLocal>>,
442    runs: Mutex<HashMap<task_state::TaskReplayKey, TaskContext>>,
443    assessments: Mutex<HashMap<String, ValueAssessment>>,
444    outcomes: Mutex<HashMap<String, LifecycleOutcome>>,
445}
446
447/// Sole owner of task admission, ordered completion, and exact-once side effects.
448#[derive(Debug, Clone)]
449pub struct ExecutionLifecycle {
450    spine: Arc<DecisionSpine>,
451}
452
453impl Default for ExecutionLifecycle {
454    fn default() -> Self {
455        Self::with_triage(TriageEngine::default())
456    }
457}
458
459impl ExecutionLifecycle {
460    pub fn global() -> &'static Self {
461        static LIFECYCLE: std::sync::OnceLock<ExecutionLifecycle> = std::sync::OnceLock::new();
462        LIFECYCLE.get_or_init(Self::default)
463    }
464
465    pub fn with_triage(triage: TriageEngine) -> Self {
466        Self::with_components(triage, ValueGate)
467    }
468
469    pub fn with_components(triage: TriageEngine, value_gate: ValueGate) -> Self {
470        Self {
471            spine: Arc::new(DecisionSpine::new(triage, value_gate)),
472        }
473    }
474
475    pub fn begin(
476        &self,
477        request: ToolRequest,
478        runtime: RuntimeContext,
479        entitlements: ProductEntitlements,
480    ) -> TaskContext {
481        self.spine.begin(request, runtime, entitlements, None)
482    }
483
484    fn prepare(&self, context: &TaskContext) {
485        let _ = context.advance(LifecycleStage::IdentifyCaller);
486        let _ = context.advance(LifecycleStage::ResolveWorkspace);
487        let _ = context.advance(LifecycleStage::ResolveEntitlements);
488        let _ = context.advance(LifecycleStage::ApplySecurityBoundaries);
489        let _ = context.advance(LifecycleStage::GatherContextStrategy);
490        if context.entitlements.autopilot {
491            let _ = context.skip(
492                LifecycleStage::AskAutopilot,
493                "no canonical autopilot decision",
494            );
495        } else {
496            let _ = context.skip(LifecycleStage::AskAutopilot, "not entitled");
497        }
498    }
499
500    pub async fn run<D>(
501        &self,
502        request: ToolRequest,
503        runtime: RuntimeContext,
504        entitlements: ProductEntitlements,
505        driver: D,
506    ) -> Result<D::Output, LifecycleRunError<D::Error>>
507    where
508        D: ExecutionDriver,
509    {
510        self.run_with_task_identity(request, runtime, entitlements, driver, None)
511            .await
512    }
513
514    /// Preserve the sole lifecycle owner while accepting an explicit host identity.
515    pub async fn run_with_task_identity<D>(
516        &self,
517        request: ToolRequest,
518        runtime: RuntimeContext,
519        entitlements: ProductEntitlements,
520        driver: D,
521        identity: Option<crate::core::task_spine::AdmittedTaskIdentity>,
522    ) -> Result<D::Output, LifecycleRunError<D::Error>>
523    where
524        D: ExecutionDriver,
525    {
526        // Begin must not leak thread-local identity onto a reusable async worker.
527        TaskSpine::scope(
528            None,
529            DEFERRED_HEATMAP.scope(
530                RefCell::new(Vec::new()),
531                self.run_scoped(request, runtime, entitlements, driver, identity),
532            ),
533        )
534        .await
535    }
536
537    async fn run_scoped<D>(
538        &self,
539        request: ToolRequest,
540        runtime: RuntimeContext,
541        entitlements: ProductEntitlements,
542        driver: D,
543        identity: Option<crate::core::task_spine::AdmittedTaskIdentity>,
544    ) -> Result<D::Output, LifecycleRunError<D::Error>>
545    where
546        D: ExecutionDriver,
547    {
548        let context = self
549            .spine
550            .begin(request, runtime, entitlements, identity.as_ref());
551        TaskSpine::scope(
552            Some(context.envelope.clone()),
553            // Keep the complete driver state out of every outer transport future.
554            Box::pin(self.run_context(context, driver)),
555        )
556        .await
557    }
558
559    async fn run_context<D>(
560        &self,
561        context: TaskContext,
562        mut driver: D,
563    ) -> Result<D::Output, LifecycleRunError<D::Error>>
564    where
565        D: ExecutionDriver,
566    {
567        if let Some(cached) = context
568            .cached_or_claim_dispatch::<D::Output, D::Error>()
569            .await
570        {
571            if matches!(cached, Err(LifecycleRunError::ReplayTypeMismatch)) {
572                return cached;
573            }
574            return match driver.validate_cached_replay(&context).await {
575                Ok(Some(current_denial)) => Ok(current_denial),
576                Ok(None) => cached,
577                Err(error) => Err(LifecycleRunError::Dispatch(error)),
578            };
579        }
580        let mut permit = DispatchPermit::new(context.shared.clone());
581        let guard = LifecycleGuard::for_run(self, context.clone(), driver.cancellation_handler());
582        *context
583            .shared
584            .state
585            .lock()
586            .unwrap_or_else(std::sync::PoisonError::into_inner) = RunState::Completing;
587        macro_rules! run_admission_stage {
588            ($method:ident, $stage:expr) => {
589                match driver.$method(&context).await {
590                    Ok(disposition) => {
591                        let _ = context.record_stage($stage, disposition);
592                    }
593                    Err(error) => {
594                        let _ = context.advance($stage);
595                        context.skip_through(LifecycleStage::RecordLedger, "admission failed");
596                        let result = Err(LifecycleRunError::Dispatch(error));
597                        let _ = guard.complete(driver.observe(&result));
598                        permit.publish(result.clone());
599                        return result;
600                    }
601                }
602            };
603        }
604
605        run_admission_stage!(identify_caller, LifecycleStage::IdentifyCaller);
606        run_admission_stage!(resolve_workspace, LifecycleStage::ResolveWorkspace);
607        run_admission_stage!(resolve_entitlements, LifecycleStage::ResolveEntitlements);
608        run_admission_stage!(
609            apply_security_boundaries,
610            LifecycleStage::ApplySecurityBoundaries
611        );
612        run_admission_stage!(
613            gather_context_strategy,
614            LifecycleStage::GatherContextStrategy
615        );
616        run_admission_stage!(ask_autopilot, LifecycleStage::AskAutopilot);
617
618        let primitive = match driver.dispatch_primitive(&context).await {
619            Ok((primitive, disposition)) => {
620                let _ = context.record_stage(LifecycleStage::DispatchPrimitive, disposition);
621                primitive
622            }
623            Err(error) => {
624                let _ = context.advance(LifecycleStage::DispatchPrimitive);
625                let _ = context.skip(LifecycleStage::ReversiblePostProcess, "dispatch failed");
626                let _ = context.skip(LifecycleStage::RecordContextIr, "dispatch failed");
627                let _ = context.skip(LifecycleStage::RecordLedger, "dispatch failed");
628                let result = Err(LifecycleRunError::Dispatch(error));
629                let _ = guard.complete(driver.observe(&result));
630                permit.publish(result.clone());
631                return result;
632            }
633        };
634        let processed = match driver.reversible_post_process(&context, primitive).await {
635            Ok((processed, disposition)) => {
636                let _ = context.record_stage(LifecycleStage::ReversiblePostProcess, disposition);
637                processed
638            }
639            Err(error) => {
640                let _ = context.advance(LifecycleStage::ReversiblePostProcess);
641                let _ = context.skip(LifecycleStage::RecordContextIr, "post-processing failed");
642                let _ = context.skip(LifecycleStage::RecordLedger, "post-processing failed");
643                let result = Err(LifecycleRunError::Dispatch(error));
644                let _ = guard.complete(driver.observe(&result));
645                permit.publish(result.clone());
646                return result;
647            }
648        };
649        let ir = driver.record_context_ir(&context, &processed).await;
650        match ir {
651            Ok(disposition) => {
652                let _ = context.record_stage(LifecycleStage::RecordContextIr, disposition);
653            }
654            Err(error) => {
655                let _ = context.advance(LifecycleStage::RecordContextIr);
656                let _ = context.skip(LifecycleStage::RecordLedger, "Context IR failed");
657                let result = Err(LifecycleRunError::Dispatch(error));
658                let _ = guard.complete(driver.observe(&result));
659                permit.publish(result.clone());
660                return result;
661            }
662        }
663        match driver.record_ledger(&context, &processed).await {
664            Ok(disposition) => {
665                let _ = context.record_stage(LifecycleStage::RecordLedger, disposition);
666            }
667            Err(error) => {
668                let _ = context.advance(LifecycleStage::RecordLedger);
669                let result = Err(LifecycleRunError::Dispatch(error));
670                let _ = guard.complete(driver.observe(&result));
671                permit.publish(result.clone());
672                return result;
673            }
674        }
675        let mut output = driver.output_from_processed(processed);
676        let provisional = Ok(output.clone());
677        let observation = driver.observe(&provisional);
678        *guard
679            .observation
680            .lock()
681            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(observation.clone());
682        let fallback_observation = observation.clone();
683        let outcome = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
684            self.finalize_through_economics(&context, observation)
685        })) {
686            Ok(outcome) => {
687                *guard
688                    .outcome
689                    .lock()
690                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(outcome.clone());
691                match driver.checkpoint(&context, &mut output).await {
692                    Ok(disposition) => {
693                        let _ = context.record_stage(LifecycleStage::Checkpoint, disposition);
694                    }
695                    Err(_) => {
696                        let _ = context.skip(LifecycleStage::Checkpoint, "checkpoint failed");
697                    }
698                }
699                let telemetry_observation = driver.observe(&Ok(output.clone()));
700                self.finish_cleanup(&context, &telemetry_observation);
701                outcome
702            }
703            Err(_) => self.finalize_after_panic(&context, fallback_observation),
704        };
705        let mut state = context
706            .shared
707            .state
708            .lock()
709            .unwrap_or_else(std::sync::PoisonError::into_inner);
710        *state = RunState::Completed(Box::new(outcome));
711        context.shared.completed.notify_all();
712        drop(state);
713        let result = Ok(output);
714        permit.publish(result.clone());
715        result
716    }
717
718    pub fn complete(
719        &self,
720        context: &TaskContext,
721        observation: CompletionObservation,
722    ) -> LifecycleOutcome {
723        self.prepare(context);
724        self.complete_ordered(context, observation).0
725    }
726
727    /// Complete an open run and report whether this call owned finalization.
728    ///
729    /// Compatibility adapters use this to preserve their historic duplicate
730    /// suppression while the lifecycle still returns a stable cached outcome
731    /// through [`Self::complete`].
732    pub fn complete_once(
733        &self,
734        context: &TaskContext,
735        observation: CompletionObservation,
736    ) -> Option<LifecycleOutcome> {
737        self.prepare(context);
738        let (outcome, completed_now) = self.complete_ordered(context, observation);
739        completed_now.then_some(outcome)
740    }
741
742    fn complete_ordered(
743        &self,
744        context: &TaskContext,
745        observation: CompletionObservation,
746    ) -> (LifecycleOutcome, bool) {
747        let mut state = context
748            .shared
749            .state
750            .lock()
751            .unwrap_or_else(std::sync::PoisonError::into_inner);
752        loop {
753            match &*state {
754                RunState::Completed(outcome) => return (outcome.as_ref().clone(), false),
755                RunState::Completing => {
756                    state = context
757                        .shared
758                        .completed
759                        .wait(state)
760                        .unwrap_or_else(std::sync::PoisonError::into_inner);
761                }
762                RunState::Open => {
763                    *state = RunState::Completing;
764                    break;
765                }
766            }
767        }
768        drop(state);
769        context.skip_through(
770            LifecycleStage::RecordLedger,
771            "surface ended before dispatch",
772        );
773
774        let fallback_observation = observation.clone();
775        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
776            self.finalize(context, observation)
777        }))
778        .unwrap_or_else(|_| {
779            tracing::error!(
780                task_id = context.task_id,
781                "execution lifecycle finalizer panicked"
782            );
783            self.finalize_after_panic(context, fallback_observation)
784        });
785        let mut state = context
786            .shared
787            .state
788            .lock()
789            .unwrap_or_else(std::sync::PoisonError::into_inner);
790        *state = RunState::Completed(Box::new(outcome.clone()));
791        context.shared.completed.notify_all();
792        (outcome, true)
793    }
794
795    fn complete_cancelled(
796        &self,
797        context: &TaskContext,
798        observation: &CompletionObservation,
799        completed_outcome: Option<LifecycleOutcome>,
800    ) -> LifecycleOutcome {
801        // A checkpoint can be cancelled after economics and the outcome are
802        // recorded. Preserve that outcome and do not replay completed effects.
803        let outcome = if let Some(outcome) = completed_outcome {
804            outcome
805        } else {
806            // Keep cleanup outside outcome computation: a failed flush cannot
807            // turn cancellation into fabricated task-quality rejection.
808            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
809                self.finalize_through_economics(context, observation.clone())
810            }))
811            .unwrap_or_else(|_| self.finalize_after_panic(context, observation.clone()))
812        };
813        context.skip_through(LifecycleStage::Checkpoint, "execution cancelled");
814        self.finish_cleanup(context, observation);
815        self.spine
816            .outcomes
817            .lock()
818            .unwrap_or_else(std::sync::PoisonError::into_inner)
819            .entry(context.task_id.clone())
820            .or_insert_with(|| outcome.clone());
821        *context
822            .shared
823            .state
824            .lock()
825            .unwrap_or_else(std::sync::PoisonError::into_inner) =
826            RunState::Completed(Box::new(outcome.clone()));
827        context.shared.completed.notify_all();
828        outcome
829    }
830
831    fn complete_owned(
832        &self,
833        context: &TaskContext,
834        observation: CompletionObservation,
835    ) -> LifecycleOutcome {
836        context.skip_through(
837            LifecycleStage::RecordLedger,
838            "surface ended before dispatch",
839        );
840        let state = context
841            .shared
842            .state
843            .lock()
844            .unwrap_or_else(std::sync::PoisonError::into_inner);
845        if let RunState::Completed(outcome) = &*state {
846            return outcome.as_ref().clone();
847        }
848        drop(state);
849        let fallback_observation = observation.clone();
850        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
851            self.finalize(context, observation)
852        }))
853        .unwrap_or_else(|_| self.finalize_after_panic(context, fallback_observation));
854        let mut state = context
855            .shared
856            .state
857            .lock()
858            .unwrap_or_else(std::sync::PoisonError::into_inner);
859        *state = RunState::Completed(Box::new(outcome.clone()));
860        context.shared.completed.notify_all();
861        outcome
862    }
863
864    fn finalize(
865        &self,
866        context: &TaskContext,
867        observation: CompletionObservation,
868    ) -> LifecycleOutcome {
869        let telemetry = observation.clone();
870        let outcome = if context.completed_stages().len() >= 15 {
871            self.outcome_for(&context.task_id)
872                .unwrap_or_else(|| self.finalize_through_economics(context, observation))
873        } else {
874            self.finalize_through_economics(context, observation)
875        };
876        let _ = context.skip(LifecycleStage::Checkpoint, "checkpoint not configured");
877        self.finish_cleanup(context, &telemetry);
878        outcome
879    }
880
881    fn finalize_through_economics(
882        &self,
883        context: &TaskContext,
884        observation: CompletionObservation,
885    ) -> LifecycleOutcome {
886        TaskSpine::set(context.envelope.clone());
887        if let Some(outcome) = context.shared.computed_outcome.get() {
888            // Never retry partially applied evidence, learning or economics.
889            context.skip_through(
890                LifecycleStage::UpdateBreakEven,
891                "finalization already attempted",
892            );
893            return outcome.clone();
894        }
895
896        #[cfg(test)]
897        test_finalization_point(context, LifecycleStage::RecordEvidence, None);
898
899        let CompletionObservation {
900            input_tokens,
901            output_tokens,
902            model,
903            provider,
904            success: _,
905            outcome_signals,
906            proxy_economics,
907            heatmap,
908        } = observation;
909        #[cfg(test)]
910        let _ = (output_tokens, &outcome_signals);
911
912        // Transport success/failure is not a quality signal or policy violation.
913        let accepted_outcome =
914            evaluate_outcome_signals(&context.task_id, &context.profile_intent, &outcome_signals);
915
916        let assessment = (accepted_outcome.accepted != AcceptanceState::Unknown
917            && !outcome_signals.is_empty())
918        .then(|| {
919            let cost = ExecutionCost {
920                input_tokens,
921                output_tokens,
922                cache_read_tokens: 0,
923                model: model.clone(),
924                provider,
925                estimated_cost_micros: calculate_cost(input_tokens, output_tokens, 0, &model),
926            };
927            let outcome = TaskOutcome {
928                task_id: context.task_id.clone(),
929                completed: true,
930                signals: outcome_signals.clone(),
931            };
932            self.spine
933                .value_gate
934                .assess(&context.task_id, &cost, &outcome)
935        });
936
937        let outcome = LifecycleOutcome {
938            accepted_outcome,
939            assessment,
940        };
941        // Retain the evaluated result before any fallible persistence effects;
942        // an evictable lookup map cannot be the sole authority during recovery.
943        let _ = context.shared.computed_outcome.set(outcome.clone());
944
945        // The assessment is the evidence (`computed_outcome` above); the
946        // former `evidence_ledger.jsonl` copy was written but never read.
947        if outcome.assessment.is_some() {
948            let _ = context.advance(LifecycleStage::RecordEvidence);
949        } else {
950            let _ = context.skip(
951                LifecycleStage::RecordEvidence,
952                "outcome has no accepted evidence",
953            );
954        }
955        if let Some(assessment) = outcome.assessment.as_ref() {
956            self.spine
957                .assessments
958                .lock()
959                .unwrap_or_else(std::sync::PoisonError::into_inner)
960                .insert(context.task_id.clone(), assessment.clone());
961        }
962        self.spine
963            .outcomes
964            .lock()
965            .unwrap_or_else(std::sync::PoisonError::into_inner)
966            .insert(context.task_id.clone(), outcome.clone());
967        let _ = context.advance(LifecycleStage::RecordOutcome);
968        if let Some(assessment) = outcome.assessment.as_ref()
969            && context.entitlements.personalized_learning
970        {
971            #[cfg(not(test))]
972            crate::core::value_gate::record_assessment(assessment);
973            #[cfg(test)]
974            let _ = assessment;
975            let _ = context.advance(LifecycleStage::UpdateLearning);
976        } else {
977            let _ = context.skip(
978                LifecycleStage::UpdateLearning,
979                "not entitled or outcome unknown",
980            );
981        }
982        let mut heatmaps = take_deferred_heatmap();
983        if let Some(heatmap) = heatmap {
984            heatmaps.push(heatmap);
985        }
986        if heatmaps.is_empty() {
987            let _ = context.skip(LifecycleStage::UpdateHeatmap, "no file access recorded");
988        } else {
989            for heatmap in heatmaps {
990                crate::core::heatmap::record_file_access(
991                    &heatmap.path,
992                    heatmap.original_tokens,
993                    heatmap.saved_tokens,
994                );
995            }
996            let _ = context.advance(LifecycleStage::UpdateHeatmap);
997        }
998        if context.surface == ToolSurface::Proxy {
999            #[cfg(test)]
1000            test_finalization_point(context, LifecycleStage::UpdateBreakEven, Some(&outcome));
1001            let economics = proxy_economics.unwrap_or_else(|| ProxyEconomicsObservation {
1002                tokens_pruned: 0,
1003                original_tokens: usize::try_from(input_tokens).unwrap_or(usize::MAX),
1004                task_class: context.triage_class.clone(),
1005            });
1006            #[cfg(not(test))]
1007            crate::core::proxy_value_metrics::record_completion(
1008                economics.tokens_pruned,
1009                economics.original_tokens,
1010                &economics.task_class,
1011                outcome.accepted_outcome.accepted,
1012            );
1013            #[cfg(test)]
1014            let _ = economics;
1015            let _ = context.advance(LifecycleStage::UpdateBreakEven);
1016        } else {
1017            let _ = context.skip(LifecycleStage::UpdateBreakEven, "no provider economics");
1018        }
1019        outcome
1020    }
1021
1022    fn finish_cleanup(&self, context: &TaskContext, observation: &CompletionObservation) {
1023        if !context
1024            .completed_stages()
1025            .contains(&LifecycleStage::ScheduleTelemetry)
1026            && std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1027                self.record_telemetry(context, observation);
1028            }))
1029            .is_err()
1030        {
1031            context.skip(LifecycleStage::ScheduleTelemetry, "telemetry panicked");
1032        }
1033        // Each effect reserves its attempt before writing. A partial failure
1034        // cannot unwind outcome publication or cause recovery to retry writes.
1035        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1036            self.flush_state(context);
1037        }));
1038    }
1039
1040    fn record_telemetry(&self, context: &TaskContext, observation: &CompletionObservation) {
1041        if context
1042            .shared
1043            .telemetry_started
1044            .swap(true, std::sync::atomic::Ordering::AcqRel)
1045        {
1046            context.skip(
1047                LifecycleStage::ScheduleTelemetry,
1048                "telemetry already attempted",
1049            );
1050            return;
1051        }
1052        #[cfg(test)]
1053        test_finalization_point(context, LifecycleStage::ScheduleTelemetry, None);
1054        #[cfg(not(test))]
1055        {
1056            // Call counts and latency are recorded once, per tool, where the
1057            // call is dispatched (`record_named_tool_failure`); counting them
1058            // here too doubled the daily totals and the latency histogram.
1059            let metrics = crate::core::telemetry::global_metrics();
1060            metrics.record_tokens(
1061                observation.input_tokens,
1062                observation.output_tokens,
1063                observation
1064                    .input_tokens
1065                    .saturating_sub(observation.output_tokens),
1066            );
1067        }
1068        #[cfg(test)]
1069        let _ = (context, observation);
1070        let _ = context.advance(LifecycleStage::ScheduleTelemetry);
1071    }
1072
1073    fn flush_state(&self, context: &TaskContext) {
1074        // Reserve the effect, not only its stage marker: panic recovery and guard
1075        // drop must never retry a partially completed flush.
1076        if context
1077            .shared
1078            .flush_started
1079            .swap(true, std::sync::atomic::Ordering::AcqRel)
1080        {
1081            return;
1082        }
1083        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1084            #[cfg(test)]
1085            {
1086                let hook = context
1087                    .shared
1088                    .flush_hook
1089                    .lock()
1090                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1091                    .clone();
1092                if let Some(hook) = hook {
1093                    hook();
1094                    return;
1095                }
1096            }
1097            crate::core::tool_lifecycle::flush_all();
1098        }));
1099        match result {
1100            Ok(()) => {
1101                let _ = context.advance(LifecycleStage::FlushState);
1102            }
1103            Err(payload) => {
1104                let _ = context.skip(LifecycleStage::FlushState, "state flush panicked");
1105                std::panic::resume_unwind(payload);
1106            }
1107        }
1108    }
1109
1110    fn finalize_after_panic(
1111        &self,
1112        context: &TaskContext,
1113        mut observation: CompletionObservation,
1114    ) -> LifecycleOutcome {
1115        observation.success = false;
1116        // Infrastructure failure is not compiler or task-quality evidence.
1117        // Preserve a completed evaluation; otherwise quality remains unknown.
1118        let outcome = context
1119            .shared
1120            .computed_outcome
1121            .get()
1122            .cloned()
1123            .unwrap_or_else(|| {
1124                let contract =
1125                    OutcomeContractV1::for_task_class(task_class(&context.profile_intent));
1126                LifecycleOutcome {
1127                    accepted_outcome: OutcomeEvaluator::new()
1128                        .evaluate_with_context(
1129                            &contract,
1130                            &[],
1131                            crate::core::outcome::evaluator::EvaluationContext::new(
1132                                &context.task_id,
1133                                &contract.contract_version,
1134                                crate::core::outcome::evaluator::EVALUATOR_VERSION,
1135                            ),
1136                        )
1137                        .outcome,
1138                    assessment: None,
1139                }
1140            });
1141        context.skip_through(
1142            LifecycleStage::UpdateBreakEven,
1143            "lifecycle finalizer failed",
1144        );
1145        let _ = context.skip(LifecycleStage::Checkpoint, "lifecycle finalizer failed");
1146        self.finish_cleanup(context, &observation);
1147        if let Some(assessment) = outcome.assessment.as_ref() {
1148            self.spine
1149                .assessments
1150                .lock()
1151                .unwrap_or_else(std::sync::PoisonError::into_inner)
1152                .insert(context.task_id.clone(), assessment.clone());
1153        }
1154        self.spine
1155            .outcomes
1156            .lock()
1157            .unwrap_or_else(std::sync::PoisonError::into_inner)
1158            .insert(context.task_id.clone(), outcome.clone());
1159        outcome
1160    }
1161
1162    pub fn profile_for_session(&self, session_id: &str) -> Option<TaskProfileLocal> {
1163        self.spine.profile_for_session(session_id)
1164    }
1165
1166    pub fn assessment_for(&self, task_id: &str) -> Option<ValueAssessment> {
1167        self.spine.assessment_for(task_id)
1168    }
1169
1170    pub fn outcome_for(&self, task_id: &str) -> Option<LifecycleOutcome> {
1171        self.spine
1172            .outcomes
1173            .lock()
1174            .unwrap_or_else(std::sync::PoisonError::into_inner)
1175            .get(task_id)
1176            .cloned()
1177    }
1178}
1179
1180/// RAII completion guard for error, timeout, and early-return paths.
1181pub struct LifecycleGuard<'a> {
1182    lifecycle: &'a ExecutionLifecycle,
1183    context: TaskContext,
1184    prepare_missing: bool,
1185    cancellation: Option<CancellationHandler>,
1186    observation: Mutex<Option<CompletionObservation>>,
1187    outcome: Mutex<Option<LifecycleOutcome>>,
1188}
1189
1190struct DispatchPermit {
1191    shared: Arc<SharedRun>,
1192    published: bool,
1193}
1194
1195impl DispatchPermit {
1196    fn new(shared: Arc<SharedRun>) -> Self {
1197        Self {
1198            shared,
1199            published: false,
1200        }
1201    }
1202
1203    fn publish<T: Send + Sync + 'static>(&mut self, result: T) {
1204        *self
1205            .shared
1206            .dispatch
1207            .lock()
1208            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1209            DispatchState::Finished(Arc::new(result));
1210        self.published = true;
1211        self.shared.dispatch_completed.notify_waiters();
1212    }
1213}
1214
1215impl Drop for DispatchPermit {
1216    fn drop(&mut self) {
1217        if self.published {
1218            return;
1219        }
1220        let mut dispatch = self
1221            .shared
1222            .dispatch
1223            .lock()
1224            .unwrap_or_else(std::sync::PoisonError::into_inner);
1225        let terminal = match &*self
1226            .shared
1227            .state
1228            .lock()
1229            .unwrap_or_else(std::sync::PoisonError::into_inner)
1230        {
1231            RunState::Completed(outcome) => Some(outcome.clone()),
1232            RunState::Open | RunState::Completing => None,
1233        };
1234        *dispatch = terminal.map_or(DispatchState::Idle, DispatchState::Aborted);
1235        self.shared.dispatch_completed.notify_waiters();
1236    }
1237}
1238
1239impl<'a> LifecycleGuard<'a> {
1240    pub fn new(lifecycle: &'a ExecutionLifecycle, context: TaskContext) -> Self {
1241        lifecycle.prepare(&context);
1242        Self {
1243            lifecycle,
1244            context,
1245            prepare_missing: true,
1246            cancellation: None,
1247            observation: Mutex::new(None),
1248            outcome: Mutex::new(None),
1249        }
1250    }
1251
1252    fn for_run(
1253        lifecycle: &'a ExecutionLifecycle,
1254        context: TaskContext,
1255        cancellation: Option<CancellationHandler>,
1256    ) -> Self {
1257        Self {
1258            lifecycle,
1259            context,
1260            prepare_missing: false,
1261            cancellation,
1262            observation: Mutex::new(None),
1263            outcome: Mutex::new(None),
1264        }
1265    }
1266
1267    pub const fn context(&self) -> &TaskContext {
1268        &self.context
1269    }
1270
1271    pub fn complete(&self, observation: CompletionObservation) -> LifecycleOutcome {
1272        if self.prepare_missing {
1273            self.lifecycle.complete(&self.context, observation)
1274        } else {
1275            self.lifecycle.complete_owned(&self.context, observation)
1276        }
1277    }
1278}
1279
1280impl Drop for LifecycleGuard<'_> {
1281    fn drop(&mut self) {
1282        if self.context.outcome().is_some() {
1283            return;
1284        }
1285        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1286            let mut observation = CompletionObservation::tool_result(0, 0, "unknown", false);
1287            // Cancellation is not compiler or task-quality evidence.
1288            observation.outcome_signals.clear();
1289            if !self
1290                .context
1291                .completed_stages()
1292                .contains(&LifecycleStage::RecordLedger)
1293            {
1294                self.context.skip_through(
1295                    LifecycleStage::RecordContextIr,
1296                    "execution cancelled before stage completed",
1297                );
1298                let mut ledger = StageDisposition::Skipped("execution cancelled before ledger");
1299                if let Some(handler) = self.cancellation.take() {
1300                    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(handler)) {
1301                        Ok(completion) => {
1302                            observation = completion.observation;
1303                            ledger = completion.ledger;
1304                        }
1305                        Err(_) => {
1306                            ledger = StageDisposition::Skipped("cancellation ledger panicked");
1307                        }
1308                    }
1309                }
1310                self.context
1311                    .record_stage(LifecycleStage::RecordLedger, ledger);
1312            }
1313            if let Some(saved) = self
1314                .observation
1315                .lock()
1316                .unwrap_or_else(std::sync::PoisonError::into_inner)
1317                .take()
1318            {
1319                observation = saved;
1320            }
1321            let completed_outcome = self
1322                .outcome
1323                .lock()
1324                .unwrap_or_else(std::sync::PoisonError::into_inner)
1325                .take();
1326            let _ = if self.prepare_missing {
1327                self.lifecycle.complete(&self.context, observation)
1328            } else {
1329                self.lifecycle
1330                    .complete_cancelled(&self.context, &observation, completed_outcome)
1331            };
1332        }));
1333    }
1334}
1335
1336/// Pure evaluation shared by real finalization and explicitly simulated proofs.
1337/// This function does not finalize a lifecycle or persist evidence.
1338pub(crate) fn evaluate_outcome_signals(
1339    task_id: &str,
1340    profile_intent: &str,
1341    signals: &[OutcomeSignal],
1342) -> AcceptedOutcomeV1 {
1343    let signals = canonical_signals(signals, task_id);
1344    let contract = OutcomeContractV1::for_task_class(task_class(profile_intent));
1345    let context = crate::core::outcome::evaluator::EvaluationContext::new(
1346        task_id,
1347        &contract.contract_version,
1348        crate::core::outcome::evaluator::EVALUATOR_VERSION,
1349    );
1350    OutcomeEvaluator::new()
1351        .evaluate_with_context(&contract, &signals, context)
1352        .outcome
1353}
1354
1355fn task_class(value: &str) -> TaskClass {
1356    match value {
1357        "coding_fix" | "bug_fix" | "debugging" => TaskClass::BugFix,
1358        "refactor" | "coding_refactor" => TaskClass::Refactor,
1359        "test_addition" => TaskClass::TestAddition,
1360        "documentation" | "docs" => TaskClass::Documentation,
1361        _ => TaskClass::Investigation,
1362    }
1363}
1364
1365fn canonical_signals(signals: &[OutcomeSignal], task_id: &str) -> Vec<CanonicalSignal> {
1366    signals
1367        .iter()
1368        .map(|signal| {
1369            let signal = match signal {
1370                OutcomeSignal::BuildSucceeded => LocalSignalAdapters::build_success(true),
1371                OutcomeSignal::TestsPassed => LocalSignalAdapters::tests_passing(true),
1372                OutcomeSignal::LintClean => LocalSignalAdapters::lint_clean(true),
1373                OutcomeSignal::UserAccepted => LocalSignalAdapters::human_acceptance(true),
1374                OutcomeSignal::UserRejected => LocalSignalAdapters::human_acceptance(false),
1375                OutcomeSignal::CompileError => LocalSignalAdapters::build_success(false),
1376                OutcomeSignal::TestFailed => LocalSignalAdapters::tests_passing(false),
1377            };
1378            signal.with_evidence_ref(format!("task:{task_id}"))
1379        })
1380        .collect()
1381}
1382
1383#[cfg(test)]
1384#[path = "execution_lifecycle_tests.rs"]
1385mod tests;