Skip to main content

deepstrike_sdk/runtime/
canonical_kernel_step.rs

1//! Durable Rust host for the canonical kernel ABI.
2//!
3//! Core prepares the typed envelope and record; the journal makes that record authoritative; only
4//! then is the planned step committed and exposed to a runner. This module exposes no alternate
5//! input vocabulary or direct-step escape hatch.
6
7use std::sync::{Arc, Mutex};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde_json::Value;
11
12#[cfg(test)]
13use serde_json::json;
14
15use crate::runtime::canonical_kernel::{
16    CanonicalCommit, CanonicalKernel, CanonicalPreparation, InputId, KernelFault, KernelFaultCode,
17    KernelInput, OperationId, PlannedStep, WireEnvelope, WireU64,
18};
19use crate::runtime::kernel_journal::{
20    CheckpointCandidate, InstalledCheckpoint, JournalError, JournalRecordInput, KernelJournal,
21};
22use crate::{Error, Result};
23
24// A moving journal may require several restore/retry passes, but a permanently contended host
25// must fail closed instead of growing the async call stack indefinitely.
26const MAX_TRANSITION_RECONCILIATIONS: usize = 8;
27
28#[derive(Debug, thiserror::Error)]
29pub enum HostTransitionError {
30    #[error("canonical kernel rejected input: {0}")]
31    Rejected(KernelFault),
32    #[error(
33        "canonical record is durable but commit could not be published; runtime rebuilt from journal"
34    )]
35    RebuildRequired,
36    #[error(transparent)]
37    Journal(#[from] JournalError),
38    #[error(transparent)]
39    Other(#[from] Error),
40}
41
42impl From<HostTransitionError> for Error {
43    fn from(value: HostTransitionError) -> Self {
44        match value {
45            HostTransitionError::Rejected(_) | HostTransitionError::RebuildRequired => {
46                Self::Other(value.to_string())
47            }
48            HostTransitionError::Journal(err) => Self::from(err),
49            HostTransitionError::Other(err) => err,
50        }
51    }
52}
53
54#[derive(Debug, Clone)]
55pub struct CanonicalTransition {
56    pub envelope: WireEnvelope,
57    pub step_seq: u64,
58    pub record_digest: String,
59    pub planned_step: PlannedStep,
60    pub checkpoint_advised: bool,
61    pub replayed: bool,
62}
63
64/// The typed prepare → append → commit host boundary for one operation.
65pub struct CanonicalKernelHost {
66    kernel: Mutex<CanonicalKernel>,
67    journal: Arc<dyn KernelJournal>,
68    operation_id: String,
69}
70
71impl CanonicalKernelHost {
72    pub fn new(
73        kernel: CanonicalKernel,
74        journal: Arc<dyn KernelJournal>,
75        operation_id: impl Into<String>,
76    ) -> Result<Self> {
77        let operation_id = operation_id.into();
78        if operation_id.is_empty() {
79            return Err(Error::Other(
80                "canonical kernel operation_id must not be empty".into(),
81            ));
82        }
83        Ok(Self {
84            kernel: Mutex::new(kernel),
85            journal,
86            operation_id,
87        })
88    }
89
90    pub fn operation_id(&self) -> &str {
91        &self.operation_id
92    }
93
94    pub fn journal(&self) -> &Arc<dyn KernelJournal> {
95        &self.journal
96    }
97
98    pub fn lifecycle(&self) -> crate::runtime::canonical_kernel::OperationLifecycle {
99        self.kernel.lock().unwrap().lifecycle()
100    }
101
102    pub fn pending_effects(&self) -> Vec<crate::runtime::canonical_kernel::KernelEffect> {
103        // Publication order — the host consumes the first pending effect as the next action,
104        // and the map's lexicographic order is not the numeric step order.
105        self.kernel
106            .lock()
107            .unwrap()
108            .pending_effects_in_order()
109            .into_iter()
110            .cloned()
111            .collect()
112    }
113
114    pub fn terminal(&self) -> Option<crate::runtime::canonical_kernel::KernelTerminal> {
115        self.kernel.lock().unwrap().terminal().cloned()
116    }
117
118    pub fn attempt_id(&self, task_id: &str) -> Option<String> {
119        self.kernel
120            .lock()
121            .unwrap()
122            .attempt_id(task_id)
123            .map(|attempt_id| attempt_id.as_str().to_string())
124    }
125
126    pub fn turn(&self) -> u32 {
127        self.kernel.lock().unwrap().turn()
128    }
129
130    pub fn recovery_content_bytes(&self) -> Option<usize> {
131        self.kernel.lock().unwrap().recovery_content_bytes()
132    }
133
134    pub fn preserved_refs(&self) -> Vec<String> {
135        self.kernel.lock().unwrap().preserved_refs()
136    }
137
138    pub fn count_tokens(&self, text: &str) -> Option<u32> {
139        self.kernel.lock().unwrap().count_tokens(text)
140    }
141
142    pub fn local_subagents_spawned(&self) -> usize {
143        self.kernel.lock().unwrap().local_subagents_spawned() as usize
144    }
145
146    pub fn new_messages(&self) -> Vec<deepstrike_core::types::message::Message> {
147        self.kernel.lock().unwrap().new_messages()
148    }
149
150    /// Stage the exact serialized typed envelope before attempting its durable append.
151    pub async fn transition(&self, envelope: WireEnvelope) -> Result<CanonicalTransition> {
152        if envelope.operation_id.as_str() != self.operation_id {
153            return Err(Error::Other(
154                "canonical envelope operation_id does not match host operation_id".into(),
155            ));
156        }
157        let staged = serde_json::to_string(&envelope).map_err(|error| {
158            Error::Other(format!("canonical envelope is not serializable: {error}"))
159        })?;
160        self.journal
161            .stage_outbound_envelope(&self.operation_id, &staged)
162            .await
163            .map_err(Error::from)?;
164        match self.transition_typed(envelope).await {
165            Ok(transition) => {
166                self.journal
167                    .clear_outbound_envelope(&self.operation_id)
168                    .await
169                    .map_err(Error::from)?;
170                Ok(transition)
171            }
172            Err(
173                error @ (HostTransitionError::Rejected(_) | HostTransitionError::RebuildRequired),
174            ) => {
175                self.journal
176                    .clear_outbound_envelope(&self.operation_id)
177                    .await
178                    .map_err(Error::from)?;
179                Err(error.into())
180            }
181            Err(error) => Err(error.into()),
182        }
183    }
184
185    /// Replays an append-window envelope byte-for-byte after a wake.
186    pub async fn drain_outbound_envelope(&self) -> Result<Option<CanonicalTransition>> {
187        let Some(staged) = self
188            .journal
189            .read_outbound_envelope(&self.operation_id)
190            .await
191            .map_err(Error::from)?
192        else {
193            return Ok(None);
194        };
195        let envelope: WireEnvelope = serde_json::from_str(&staged).map_err(|error| {
196            Error::Other(format!(
197                "staged canonical outbound envelope is malformed: {error}"
198            ))
199        })?;
200        match self.transition_typed(envelope).await {
201            Ok(transition) => {
202                self.journal
203                    .clear_outbound_envelope(&self.operation_id)
204                    .await
205                    .map_err(Error::from)?;
206                Ok(Some(transition))
207            }
208            Err(
209                error @ (HostTransitionError::Rejected(_) | HostTransitionError::RebuildRequired),
210            ) => {
211                self.journal
212                    .clear_outbound_envelope(&self.operation_id)
213                    .await
214                    .map_err(Error::from)?;
215                Err(error.into())
216            }
217            Err(error) => Err(error.into()),
218        }
219    }
220
221    /// Restore the typed kernel solely from the durable journal.
222    pub async fn restore(&self) -> Result<()> {
223        self.restore_typed().await.map_err(Error::from)
224    }
225
226    /// Execute the install → acknowledge → reclaim checkpoint boundary.
227    pub async fn checkpoint(&self) -> Result<InstalledCheckpoint> {
228        self.checkpoint_typed().await.map_err(Error::from)
229    }
230
231    async fn restore_typed(&self) -> std::result::Result<(), HostTransitionError> {
232        let checkpoint = self
233            .journal
234            .latest_checkpoint(&self.operation_id)
235            .await
236            .map_err(HostTransitionError::Journal)?;
237        let records = self
238            .journal
239            .records_after(
240                &self.operation_id,
241                checkpoint
242                    .as_ref()
243                    .map(|checkpoint| checkpoint.covered_head.as_str()),
244            )
245            .await
246            .map_err(HostTransitionError::Journal)?;
247        self.kernel
248            .lock()
249            .unwrap()
250            .restore_bytes(
251                checkpoint
252                    .as_ref()
253                    .map(|checkpoint| checkpoint.checkpoint_bytes.as_slice()),
254                &records
255                    .iter()
256                    .map(|record| record.record_bytes.clone())
257                    .collect::<Vec<_>>(),
258            )
259            .map_err(HostTransitionError::Rejected)?;
260        Ok(())
261    }
262
263    async fn checkpoint_typed(
264        &self,
265    ) -> std::result::Result<InstalledCheckpoint, HostTransitionError> {
266        let candidate = self
267            .kernel
268            .lock()
269            .unwrap()
270            .checkpoint_candidate()
271            .map_err(HostTransitionError::Rejected)?;
272        let previous = self
273            .journal
274            .latest_checkpoint(&self.operation_id)
275            .await
276            .map_err(HostTransitionError::Journal)?;
277        let checkpoint = CheckpointCandidate {
278            checkpoint_id: candidate.ack_token.as_str().to_string(),
279            through_step_seq: candidate.through_step_seq.get(),
280            state_digest: candidate.state_digest.as_str().to_string(),
281            checkpoint_bytes: candidate.checkpoint_bytes.as_slice().to_vec(),
282        };
283        let installed = match self
284            .journal
285            .compare_and_install_checkpoint(
286                &self.operation_id,
287                previous
288                    .as_ref()
289                    .map(|checkpoint| checkpoint.checkpoint_id.as_str()),
290                candidate.covered_head.as_str(),
291                checkpoint,
292            )
293            .await
294        {
295            Ok(installed) => installed,
296            Err(error @ JournalError::CasConflict(_)) => {
297                let winner = self
298                    .journal
299                    .latest_checkpoint(&self.operation_id)
300                    .await
301                    .map_err(HostTransitionError::Journal)?;
302                match winner {
303                    Some(winner) if winner.checkpoint_id == candidate.ack_token.as_str() => winner,
304                    _ => return Err(error.into()),
305                }
306            }
307            Err(error) => return Err(HostTransitionError::Journal(error)),
308        };
309        self.journal
310            .ack_checkpoint(&self.operation_id, &installed.checkpoint_id)
311            .await
312            .map_err(HostTransitionError::Journal)?;
313        self.kernel
314            .lock()
315            .unwrap()
316            .note_checkpoint_acked(&candidate.boundary())
317            .map_err(HostTransitionError::Rejected)?;
318        self.journal
319            .prune_acked_prefix(&self.operation_id)
320            .await
321            .map_err(HostTransitionError::Journal)?;
322        Ok(InstalledCheckpoint {
323            acknowledged: true,
324            ..installed
325        })
326    }
327
328    async fn transition_typed(
329        &self,
330        envelope: WireEnvelope,
331    ) -> std::result::Result<CanonicalTransition, HostTransitionError> {
332        let mut reconciliations = 0;
333        loop {
334            let preparation = self.kernel.lock().unwrap().prepare(&envelope);
335            match preparation {
336                CanonicalPreparation::Rejected(rejected)
337                    if rejected.fault.code == KernelFaultCode::CheckpointRequired =>
338                {
339                    if reconciliations >= MAX_TRANSITION_RECONCILIATIONS {
340                        return Err(HostTransitionError::Other(Error::Other(format!(
341                            "canonical transition still requires a checkpoint after {reconciliations} reconciliations: {}",
342                            rejected.fault.message
343                        ))));
344                    }
345                    reconciliations += 1;
346                    self.checkpoint_typed().await?;
347                }
348                CanonicalPreparation::Rejected(rejected) => {
349                    return Err(HostTransitionError::Rejected(rejected.fault));
350                }
351                CanonicalPreparation::Replayed(replayed) => {
352                    let planned_step = replayed.committed_step.ok_or_else(|| {
353                        HostTransitionError::Other(Error::Other(
354                            "canonical replay has no reproducible planned step".into(),
355                        ))
356                    })?;
357                    return Ok(CanonicalTransition {
358                        envelope,
359                        step_seq: replayed.step_seq.get(),
360                        record_digest: replayed.record_digest.as_str().to_string(),
361                        planned_step,
362                        checkpoint_advised: false,
363                        replayed: true,
364                    });
365                }
366                CanonicalPreparation::Prepared(prepared) => {
367                    let record = prepared.record.clone();
368                    let append = self
369                        .journal
370                        .compare_and_append(
371                            &self.operation_id,
372                            record
373                                .previous_record_digest()
374                                .map(|digest| digest.as_str()),
375                            JournalRecordInput {
376                                step_seq: record.step_seq().get(),
377                                record_digest: record.record_digest().as_str().to_string(),
378                                record_bytes: record.record_bytes().into_vec(),
379                            },
380                        )
381                        .await;
382                    let receipt = match append {
383                        Ok(receipt) => receipt,
384                        Err(error) => {
385                            self.kernel
386                                .lock()
387                                .unwrap()
388                                .abort(&prepared.token)
389                                .map_err(HostTransitionError::Rejected)?;
390                            if error.is_retryable() {
391                                if reconciliations >= MAX_TRANSITION_RECONCILIATIONS {
392                                    return Err(HostTransitionError::Journal(error));
393                                }
394                                reconciliations += 1;
395                                self.restore_typed().await?;
396                                continue;
397                            }
398                            return Err(HostTransitionError::Journal(error));
399                        }
400                    };
401                    let committed: CanonicalCommit = match self
402                        .kernel
403                        .lock()
404                        .unwrap()
405                        .commit(&prepared.token, record.record_digest())
406                    {
407                        Ok(committed) => committed,
408                        Err(_) => {
409                            self.restore_typed().await?;
410                            return Err(HostTransitionError::RebuildRequired);
411                        }
412                    };
413                    if committed.step_seq.get() != receipt.step_seq
414                        || committed.record.record_digest().as_str() != receipt.record_digest
415                    {
416                        self.restore_typed().await?;
417                        return Err(HostTransitionError::RebuildRequired);
418                    }
419                    let checkpoint_advised = committed.checkpoint_advice.is_some();
420                    let transition = CanonicalTransition {
421                        envelope,
422                        step_seq: committed.step_seq.get(),
423                        record_digest: committed.record.record_digest().as_str().to_string(),
424                        planned_step: committed.step,
425                        checkpoint_advised,
426                        replayed: false,
427                    };
428                    if checkpoint_advised {
429                        self.checkpoint_typed().await?;
430                    }
431                    return Ok(transition);
432                }
433            }
434        }
435    }
436
437    pub fn next_observed_at_ms() -> u64 {
438        SystemTime::now()
439            .duration_since(UNIX_EPOCH)
440            .unwrap_or_default()
441            .as_millis() as u64
442    }
443
444    /// Build a typed envelope from a JSON wire input object (the five-class taxonomy).
445    pub async fn transition_input(&self, input: Value) -> Result<CanonicalTransition> {
446        self.transition_input_correlated(
447            input,
448            format!("rust-input-{}", uuid::Uuid::new_v4()),
449            Self::next_observed_at_ms(),
450        )
451        .await
452    }
453
454    /// Build a typed envelope with caller-owned identity and observation time.
455    ///
456    /// Durable hosts use this entry point when the input already has a stable delivery identity.
457    /// Retrying the same input must reuse both values so core can distinguish replay from conflict.
458    pub async fn transition_input_correlated(
459        &self,
460        input: Value,
461        input_id: impl Into<String>,
462        observed_at_ms: u64,
463    ) -> Result<CanonicalTransition> {
464        let input: KernelInput = serde_json::from_value(input)
465            .map_err(|error| Error::Other(format!("canonical wire input is malformed: {error}")))?;
466        let operation_id = OperationId::new(self.operation_id.clone())
467            .map_err(|error| Error::Other(error.to_string()))?;
468        let input_id =
469            InputId::new(input_id.into()).map_err(|error| Error::Other(error.to_string()))?;
470        let envelope =
471            WireEnvelope::new(operation_id, input_id, WireU64::new(observed_at_ms), input);
472        self.transition(envelope).await
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[tokio::test]
481    async fn transitions_and_replays_a_typed_canonical_envelope() {
482        let fixture: serde_json::Value = serde_json::from_str(include_str!(
483            "../../../tests/fixtures/kernel-wire/golden_lifecycle_agent_root.json"
484        ))
485        .expect("fixture");
486        let envelope: WireEnvelope =
487            serde_json::from_value(fixture["links"][0]["envelope"].clone()).expect("envelope");
488        let journal: Arc<dyn KernelJournal> =
489            Arc::new(crate::runtime::kernel_journal::InMemoryKernelJournal::new());
490        let host = CanonicalKernelHost::new(
491            CanonicalKernel::default(),
492            journal,
493            envelope.operation_id.as_str(),
494        )
495        .expect("host");
496
497        let first = host.transition(envelope.clone()).await.expect("transition");
498        assert!(!first.replayed);
499        assert_eq!(first.step_seq, 0);
500        assert!(host.pending_effects().is_empty());
501
502        let replay = host.transition(envelope).await.expect("replay");
503        assert!(replay.replayed);
504        assert_eq!(replay.record_digest, first.record_digest);
505    }
506
507    #[tokio::test]
508    async fn correlated_input_preserves_caller_identity_and_clock() {
509        let journal: Arc<dyn KernelJournal> =
510            Arc::new(crate::runtime::kernel_journal::InMemoryKernelJournal::new());
511        let host =
512            CanonicalKernelHost::new(CanonicalKernel::default(), journal, "op-correlated-input")
513                .expect("host");
514
515        let transition = host
516            .transition_input_correlated(
517                json!({
518                    "kind": "configure_operation",
519                    "config": {
520                        "host_effect_support": { "supported": ["call_provider"] }
521                    }
522                }),
523                "delivery-42",
524                1_700_000_000_123,
525            )
526            .await
527            .expect("transition");
528
529        assert_eq!(transition.envelope.input_id.as_str(), "delivery-42");
530        assert_eq!(transition.envelope.observed_at_ms.get(), 1_700_000_000_123);
531    }
532}