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
104 .lock()
105 .unwrap()
106 .pending_effects()
107 .cloned()
108 .collect()
109 }
110
111 pub fn terminal(&self) -> Option<crate::runtime::canonical_kernel::KernelTerminal> {
112 self.kernel.lock().unwrap().terminal().cloned()
113 }
114
115 pub fn attempt_id(&self, task_id: &str) -> Option<String> {
116 self.kernel
117 .lock()
118 .unwrap()
119 .attempt_id(task_id)
120 .map(|attempt_id| attempt_id.as_str().to_string())
121 }
122
123 pub fn turn(&self) -> u32 {
124 self.kernel.lock().unwrap().turn()
125 }
126
127 pub fn recovery_content_bytes(&self) -> Option<usize> {
128 self.kernel.lock().unwrap().recovery_content_bytes()
129 }
130
131 pub fn preserved_refs(&self) -> Vec<String> {
132 self.kernel.lock().unwrap().preserved_refs()
133 }
134
135 pub fn count_tokens(&self, text: &str) -> Option<u32> {
136 self.kernel.lock().unwrap().count_tokens(text)
137 }
138
139 pub fn local_subagents_spawned(&self) -> usize {
140 self.kernel.lock().unwrap().local_subagents_spawned() as usize
141 }
142
143 pub fn new_messages(&self) -> Vec<deepstrike_core::types::message::Message> {
144 self.kernel.lock().unwrap().new_messages()
145 }
146
147 pub async fn transition(&self, envelope: WireEnvelope) -> Result<CanonicalTransition> {
149 if envelope.operation_id.as_str() != self.operation_id {
150 return Err(Error::Other(
151 "canonical envelope operation_id does not match host operation_id".into(),
152 ));
153 }
154 let staged = serde_json::to_string(&envelope).map_err(|error| {
155 Error::Other(format!("canonical envelope is not serializable: {error}"))
156 })?;
157 self.journal
158 .stage_outbound_envelope(&self.operation_id, &staged)
159 .await
160 .map_err(Error::from)?;
161 match self.transition_typed(envelope).await {
162 Ok(transition) => {
163 self.journal
164 .clear_outbound_envelope(&self.operation_id)
165 .await
166 .map_err(Error::from)?;
167 Ok(transition)
168 }
169 Err(
170 error @ (HostTransitionError::Rejected(_) | HostTransitionError::RebuildRequired),
171 ) => {
172 self.journal
173 .clear_outbound_envelope(&self.operation_id)
174 .await
175 .map_err(Error::from)?;
176 Err(error.into())
177 }
178 Err(error) => Err(error.into()),
179 }
180 }
181
182 pub async fn drain_outbound_envelope(&self) -> Result<Option<CanonicalTransition>> {
184 let Some(staged) = self
185 .journal
186 .read_outbound_envelope(&self.operation_id)
187 .await
188 .map_err(Error::from)?
189 else {
190 return Ok(None);
191 };
192 let envelope: WireEnvelope = serde_json::from_str(&staged).map_err(|error| {
193 Error::Other(format!(
194 "staged canonical outbound envelope is malformed: {error}"
195 ))
196 })?;
197 match self.transition_typed(envelope).await {
198 Ok(transition) => {
199 self.journal
200 .clear_outbound_envelope(&self.operation_id)
201 .await
202 .map_err(Error::from)?;
203 Ok(Some(transition))
204 }
205 Err(
206 error @ (HostTransitionError::Rejected(_) | HostTransitionError::RebuildRequired),
207 ) => {
208 self.journal
209 .clear_outbound_envelope(&self.operation_id)
210 .await
211 .map_err(Error::from)?;
212 Err(error.into())
213 }
214 Err(error) => Err(error.into()),
215 }
216 }
217
218 pub async fn restore(&self) -> Result<()> {
220 self.restore_typed().await.map_err(Error::from)
221 }
222
223 pub async fn checkpoint(&self) -> Result<InstalledCheckpoint> {
225 self.checkpoint_typed().await.map_err(Error::from)
226 }
227
228 async fn restore_typed(&self) -> std::result::Result<(), HostTransitionError> {
229 let checkpoint = self
230 .journal
231 .latest_checkpoint(&self.operation_id)
232 .await
233 .map_err(HostTransitionError::Journal)?;
234 let records = self
235 .journal
236 .records_after(
237 &self.operation_id,
238 checkpoint
239 .as_ref()
240 .map(|checkpoint| checkpoint.covered_head.as_str()),
241 )
242 .await
243 .map_err(HostTransitionError::Journal)?;
244 self.kernel
245 .lock()
246 .unwrap()
247 .restore_bytes(
248 checkpoint
249 .as_ref()
250 .map(|checkpoint| checkpoint.checkpoint_bytes.as_slice()),
251 &records
252 .iter()
253 .map(|record| record.record_bytes.clone())
254 .collect::<Vec<_>>(),
255 )
256 .map_err(HostTransitionError::Rejected)?;
257 Ok(())
258 }
259
260 async fn checkpoint_typed(
261 &self,
262 ) -> std::result::Result<InstalledCheckpoint, HostTransitionError> {
263 let candidate = self
264 .kernel
265 .lock()
266 .unwrap()
267 .checkpoint_candidate()
268 .map_err(HostTransitionError::Rejected)?;
269 let previous = self
270 .journal
271 .latest_checkpoint(&self.operation_id)
272 .await
273 .map_err(HostTransitionError::Journal)?;
274 let checkpoint = CheckpointCandidate {
275 checkpoint_id: candidate.ack_token.as_str().to_string(),
276 through_step_seq: candidate.through_step_seq.get(),
277 state_digest: candidate.state_digest.as_str().to_string(),
278 checkpoint_bytes: candidate.checkpoint_bytes.as_slice().to_vec(),
279 };
280 let installed = match self
281 .journal
282 .compare_and_install_checkpoint(
283 &self.operation_id,
284 previous
285 .as_ref()
286 .map(|checkpoint| checkpoint.checkpoint_id.as_str()),
287 candidate.covered_head.as_str(),
288 checkpoint,
289 )
290 .await
291 {
292 Ok(installed) => installed,
293 Err(error @ JournalError::CasConflict(_)) => {
294 let winner = self
295 .journal
296 .latest_checkpoint(&self.operation_id)
297 .await
298 .map_err(HostTransitionError::Journal)?;
299 match winner {
300 Some(winner) if winner.checkpoint_id == candidate.ack_token.as_str() => winner,
301 _ => return Err(error.into()),
302 }
303 }
304 Err(error) => return Err(HostTransitionError::Journal(error)),
305 };
306 self.journal
307 .ack_checkpoint(&self.operation_id, &installed.checkpoint_id)
308 .await
309 .map_err(HostTransitionError::Journal)?;
310 self.kernel
311 .lock()
312 .unwrap()
313 .note_checkpoint_acked(&candidate.boundary())
314 .map_err(HostTransitionError::Rejected)?;
315 self.journal
316 .prune_acked_prefix(&self.operation_id)
317 .await
318 .map_err(HostTransitionError::Journal)?;
319 Ok(InstalledCheckpoint {
320 acknowledged: true,
321 ..installed
322 })
323 }
324
325 async fn transition_typed(
326 &self,
327 envelope: WireEnvelope,
328 ) -> std::result::Result<CanonicalTransition, HostTransitionError> {
329 let mut reconciliations = 0;
330 loop {
331 let preparation = self.kernel.lock().unwrap().prepare(&envelope);
332 match preparation {
333 CanonicalPreparation::Rejected(rejected)
334 if rejected.fault.code == KernelFaultCode::CheckpointRequired =>
335 {
336 if reconciliations >= MAX_TRANSITION_RECONCILIATIONS {
337 return Err(HostTransitionError::Other(Error::Other(format!(
338 "canonical transition still requires a checkpoint after {reconciliations} reconciliations: {}",
339 rejected.fault.message
340 ))));
341 }
342 reconciliations += 1;
343 self.checkpoint_typed().await?;
344 }
345 CanonicalPreparation::Rejected(rejected) => {
346 return Err(HostTransitionError::Rejected(rejected.fault));
347 }
348 CanonicalPreparation::Replayed(replayed) => {
349 let planned_step = replayed.committed_step.ok_or_else(|| {
350 HostTransitionError::Other(Error::Other(
351 "canonical replay has no reproducible planned step".into(),
352 ))
353 })?;
354 return Ok(CanonicalTransition {
355 envelope,
356 step_seq: replayed.step_seq.get(),
357 record_digest: replayed.record_digest.as_str().to_string(),
358 planned_step,
359 checkpoint_advised: false,
360 replayed: true,
361 });
362 }
363 CanonicalPreparation::Prepared(prepared) => {
364 let record = prepared.record.clone();
365 let append = self
366 .journal
367 .compare_and_append(
368 &self.operation_id,
369 record
370 .previous_record_digest()
371 .map(|digest| digest.as_str()),
372 JournalRecordInput {
373 step_seq: record.step_seq().get(),
374 record_digest: record.record_digest().as_str().to_string(),
375 record_bytes: record.record_bytes().into_vec(),
376 },
377 )
378 .await;
379 let receipt = match append {
380 Ok(receipt) => receipt,
381 Err(error) => {
382 self.kernel
383 .lock()
384 .unwrap()
385 .abort(&prepared.token)
386 .map_err(HostTransitionError::Rejected)?;
387 if error.is_retryable() {
388 if reconciliations >= MAX_TRANSITION_RECONCILIATIONS {
389 return Err(HostTransitionError::Journal(error));
390 }
391 reconciliations += 1;
392 self.restore_typed().await?;
393 continue;
394 }
395 return Err(HostTransitionError::Journal(error));
396 }
397 };
398 let committed: CanonicalCommit = match self
399 .kernel
400 .lock()
401 .unwrap()
402 .commit(&prepared.token, record.record_digest())
403 {
404 Ok(committed) => committed,
405 Err(_) => {
406 self.restore_typed().await?;
407 return Err(HostTransitionError::RebuildRequired);
408 }
409 };
410 if committed.step_seq.get() != receipt.step_seq
411 || committed.record.record_digest().as_str() != receipt.record_digest
412 {
413 self.restore_typed().await?;
414 return Err(HostTransitionError::RebuildRequired);
415 }
416 let checkpoint_advised = committed.checkpoint_advice.is_some();
417 let transition = CanonicalTransition {
418 envelope,
419 step_seq: committed.step_seq.get(),
420 record_digest: committed.record.record_digest().as_str().to_string(),
421 planned_step: committed.step,
422 checkpoint_advised,
423 replayed: false,
424 };
425 if checkpoint_advised {
426 self.checkpoint_typed().await?;
427 }
428 return Ok(transition);
429 }
430 }
431 }
432 }
433
434 pub fn next_observed_at_ms() -> u64 {
435 SystemTime::now()
436 .duration_since(UNIX_EPOCH)
437 .unwrap_or_default()
438 .as_millis() as u64
439 }
440
441 pub async fn transition_input(&self, input: Value) -> Result<CanonicalTransition> {
443 self.transition_input_correlated(
444 input,
445 format!("rust-input-{}", uuid::Uuid::new_v4()),
446 Self::next_observed_at_ms(),
447 )
448 .await
449 }
450
451 pub async fn transition_input_correlated(
456 &self,
457 input: Value,
458 input_id: impl Into<String>,
459 observed_at_ms: u64,
460 ) -> Result<CanonicalTransition> {
461 let input: KernelInput = serde_json::from_value(input)
462 .map_err(|error| Error::Other(format!("canonical wire input is malformed: {error}")))?;
463 let operation_id = OperationId::new(self.operation_id.clone())
464 .map_err(|error| Error::Other(error.to_string()))?;
465 let input_id =
466 InputId::new(input_id.into()).map_err(|error| Error::Other(error.to_string()))?;
467 let envelope =
468 WireEnvelope::new(operation_id, input_id, WireU64::new(observed_at_ms), input);
469 self.transition(envelope).await
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[tokio::test]
478 async fn transitions_and_replays_a_typed_canonical_envelope() {
479 let fixture: serde_json::Value = serde_json::from_str(include_str!(
480 "../../../tests/fixtures/kernel-wire/golden_lifecycle_agent_root.json"
481 ))
482 .expect("fixture");
483 let envelope: WireEnvelope =
484 serde_json::from_value(fixture["links"][0]["envelope"].clone()).expect("envelope");
485 let journal: Arc<dyn KernelJournal> =
486 Arc::new(crate::runtime::kernel_journal::InMemoryKernelJournal::new());
487 let host = CanonicalKernelHost::new(
488 CanonicalKernel::default(),
489 journal,
490 envelope.operation_id.as_str(),
491 )
492 .expect("host");
493
494 let first = host.transition(envelope.clone()).await.expect("transition");
495 assert!(!first.replayed);
496 assert_eq!(first.step_seq, 0);
497 assert!(host.pending_effects().is_empty());
498
499 let replay = host.transition(envelope).await.expect("replay");
500 assert!(replay.replayed);
501 assert_eq!(replay.record_digest, first.record_digest);
502 }
503
504 #[tokio::test]
505 async fn correlated_input_preserves_caller_identity_and_clock() {
506 let journal: Arc<dyn KernelJournal> =
507 Arc::new(crate::runtime::kernel_journal::InMemoryKernelJournal::new());
508 let host =
509 CanonicalKernelHost::new(CanonicalKernel::default(), journal, "op-correlated-input")
510 .expect("host");
511
512 let transition = host
513 .transition_input_correlated(
514 json!({
515 "kind": "configure_operation",
516 "config": {
517 "host_effect_support": { "supported": ["call_provider"] }
518 }
519 }),
520 "delivery-42",
521 1_700_000_000_123,
522 )
523 .await
524 .expect("transition");
525
526 assert_eq!(transition.envelope.input_id.as_str(), "delivery-42");
527 assert_eq!(transition.envelope.observed_at_ms.get(), 1_700_000_000_123);
528 }
529}