1use 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
24const 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
64pub 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 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 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 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 pub async fn restore(&self) -> Result<()> {
223 self.restore_typed().await.map_err(Error::from)
224 }
225
226 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 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 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}