1pub mod node_projection;
4
5use hatchery_observation_api::{
6 ManagedRecordLink, ManagedSessionKey, NodeCursor, NodeId, NodeIncarnationId,
7 ObservationApiError, ObservationGap, ObservationIngressEnvelope, ObservationIngressPayload,
8 ObservationRecordInventory, ObservationResyncBatch, ObservationTarget,
9 ProjectionAvailability, ProjectionFreshness,
10 RuntimeSessionKey, CURSOR_JOURNAL_MAX, FILES_PER_SESSION_MAX, INGRESS_BATCH_MAX,
11 INTERACTIONS_PER_SESSION_MAX, NODE_ROUTES_MAX, OWNED_PROCESSES_PER_SESSION_MAX,
12 PROJECTIONS_MAX, RETIRED_INCARNATIONS_MAX, SUBAGENTS_PER_SESSION_MAX,
13 TIMELINE_PER_SESSION_MAX, TOOLS_PER_SESSION_MAX,
14};
15use hatchery_observation_protocol::{
16 ObservationCapabilitiesV1, ObservationEvidenceV1, ObservationInteractionOutcomeV1,
17 ObservationKindV1, ObservationSourceFamilyV1, ObservationTodoItemV1,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, HashSet, VecDeque};
21use thiserror::Error;
22
23#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
24pub enum ApplyOutcome {
25 Applied,
26 Duplicate,
27}
28
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub enum CorrelationState {
31 Pending,
32 Completed { success: Option<bool> },
33 Resolved { outcome: ObservationInteractionOutcomeV1 },
34 UnknownAfterGap,
35 OrphanCompletion { success: Option<bool> },
36 OrphanResolution { outcome: ObservationInteractionOutcomeV1 },
37}
38
39impl CorrelationState {
40 fn is_active(&self) -> bool {
41 matches!(self, Self::Pending)
42 }
43}
44
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
46pub struct CorrelationProjection {
47 pub correlation_id: String,
48 pub class: Option<String>,
49 pub evidence: ObservationEvidenceV1,
50 pub state: CorrelationState,
51}
52
53#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
54pub struct FileProjection {
55 pub path: Option<String>,
56 pub evidence: ObservationEvidenceV1,
57}
58
59#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
60pub struct UsageTotals {
61 pub input_tokens: u64,
62 pub output_tokens: u64,
63 pub cache_read_tokens: u64,
64 pub cache_write_tokens: u64,
65 pub reasoning_tokens: u64,
66}
67
68impl UsageTotals {
69 fn checked_delta(self, previous: Self) -> Option<Self> {
70 Some(Self {
71 input_tokens: self.input_tokens.checked_sub(previous.input_tokens)?,
72 output_tokens: self.output_tokens.checked_sub(previous.output_tokens)?,
73 cache_read_tokens: self.cache_read_tokens.checked_sub(previous.cache_read_tokens)?,
74 cache_write_tokens: self.cache_write_tokens.checked_sub(previous.cache_write_tokens)?,
75 reasoning_tokens: self.reasoning_tokens.checked_sub(previous.reasoning_tokens)?,
76 })
77 }
78
79 fn saturating_add(self, other: Self) -> Self {
80 Self {
81 input_tokens: self.input_tokens.saturating_add(other.input_tokens),
82 output_tokens: self.output_tokens.saturating_add(other.output_tokens),
83 cache_read_tokens: self.cache_read_tokens.saturating_add(other.cache_read_tokens),
84 cache_write_tokens: self.cache_write_tokens.saturating_add(other.cache_write_tokens),
85 reasoning_tokens: self.reasoning_tokens.saturating_add(other.reasoning_tokens),
86 }
87 }
88}
89
90#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
91#[serde(rename_all = "kebab-case")]
92pub enum ContextOccupancyProvenance {
93 CumulativeUsage,
94 PerTurnUsageSynthesis,
95 ExactCurrentWindow,
96}
97
98#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
99pub struct ContextOccupancySnapshot {
100 pub uncached_input_tokens: Option<u64>,
106 pub output_tokens: Option<u64>,
107 pub cache_read_tokens: Option<u64>,
108 pub cache_write_tokens: Option<u64>,
109 pub reasoning_tokens: Option<u64>,
110 pub unattributed_tokens: Option<u64>,
111 pub used_tokens: u64,
112 pub context_window: Option<u64>,
113 pub evidence: ObservationEvidenceV1,
114 pub provenance: ContextOccupancyProvenance,
115}
116
117impl ContextOccupancySnapshot {
118 pub fn occupied_tokens(self) -> u64 {
119 self.used_tokens
120 }
121}
122
123#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
124pub struct UsageProjection {
125 pub observed_delta: UsageTotals,
126 pub last_cumulative: Option<UsageTotals>,
127 pub cumulative_evidence: Option<ObservationEvidenceV1>,
128 pub context_window: Option<u64>,
129 #[serde(default)]
130 pub context_occupancy: Option<ContextOccupancySnapshot>,
131}
132
133#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
134pub struct TodoRevisionProjection {
135 pub revision: u64,
136 pub items: Vec<ObservationTodoItemV1>,
137 pub complete: bool,
138 pub evidence: ObservationEvidenceV1,
139}
140
141#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
142pub struct TodoProjection {
143 pub current: Option<TodoRevisionProjection>,
144 pub previous: Option<TodoRevisionProjection>,
145 pub conflict: bool,
146}
147
148#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
149pub struct TimelineEntry {
150 pub cursor: NodeCursor,
151 pub received_at_ms: u64,
152 pub evidence: ObservationEvidenceV1,
153 pub kind: ObservationKindV1,
154}
155
156#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
157pub struct SourceCapabilitiesProjection {
158 pub evidence: ObservationEvidenceV1,
159 pub source_family: ObservationSourceFamilyV1,
160 pub source_adapter: String,
161 pub capabilities: ObservationCapabilitiesV1,
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165pub struct HistoryProjection {
166 pub evidence: ObservationEvidenceV1,
167 pub message_count: u64,
168 pub message_count_exact: bool,
169 pub completed_turn_count: Option<u64>,
170 pub total_tokens: Option<u64>,
171}
172
173#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
174pub struct SessionProjection {
175 pub target: ObservationTarget,
176 pub availability: ProjectionAvailability,
177 pub freshness: ProjectionFreshness,
178 pub transport_incomplete: bool,
179 pub incomplete_evidence: Vec<ObservationEvidenceV1>,
180 pub stale_evidence: Vec<ObservationEvidenceV1>,
181 pub source_capabilities: Vec<SourceCapabilitiesProjection>,
182 pub history: Option<HistoryProjection>,
183 pub todos: TodoProjection,
184 pub tools: Vec<CorrelationProjection>,
185 pub subagents: Vec<CorrelationProjection>,
186 pub interactions: Vec<CorrelationProjection>,
187 pub owned_processes: Vec<CorrelationProjection>,
188 pub files: VecDeque<FileProjection>,
189 pub usage: UsageProjection,
190 pub timeline: VecDeque<TimelineEntry>,
191 #[serde(default)]
198 pub blocked_count: u64,
199 #[serde(default)]
203 pub last_blocked_at_ms: Option<u64>,
204}
205
206impl SessionProjection {
207 fn new(target: ObservationTarget) -> Self {
208 Self {
209 target,
210 availability: ProjectionAvailability::Unknown,
211 freshness: ProjectionFreshness::Unavailable,
212 transport_incomplete: false,
213 incomplete_evidence: Vec::new(),
214 stale_evidence: Vec::new(),
215 source_capabilities: Vec::new(),
216 history: None,
217 todos: TodoProjection::default(),
218 tools: Vec::new(),
219 subagents: Vec::new(),
220 interactions: Vec::new(),
221 owned_processes: Vec::new(),
222 files: VecDeque::new(),
223 usage: UsageProjection::default(),
224 timeline: VecDeque::new(),
225 blocked_count: 0,
226 last_blocked_at_ms: None,
227 }
228 }
229
230 fn mark_observed(&mut self) {
231 if matches!(
232 self.availability,
233 ProjectionAvailability::Unknown | ProjectionAvailability::NotObserved
234 ) || (self.availability == ProjectionAvailability::Frozen
235 && self.freshness == ProjectionFreshness::Unavailable)
236 {
237 self.availability = ProjectionAvailability::Current;
238 }
239 if self.availability != ProjectionAvailability::Frozen
240 && !self.transport_incomplete
241 && self.incomplete_evidence.is_empty()
242 && self.stale_evidence.is_empty()
243 {
244 self.freshness = ProjectionFreshness::Live;
245 }
246 }
247
248 fn mark_inventory_present(&mut self, linked: bool) {
249 if self.availability == ProjectionAvailability::Frozen
250 && self.freshness == ProjectionFreshness::ReplacedIncarnation
251 {
252 return;
253 }
254 if self.transport_incomplete || !self.incomplete_evidence.is_empty() {
255 self.availability = ProjectionAvailability::Partial;
256 self.freshness = ProjectionFreshness::IncompleteAfterGap;
257 return;
258 }
259
260 let observed = !self.timeline.is_empty() || !self.source_capabilities.is_empty();
261 if !observed {
262 self.availability = ProjectionAvailability::NotObserved;
263 self.freshness = ProjectionFreshness::LastKnown;
264 } else if !self.stale_evidence.is_empty() {
265 self.availability = ProjectionAvailability::Current;
266 self.freshness = ProjectionFreshness::Stale;
267 } else {
268 self.availability = ProjectionAvailability::Current;
269 if !linked
270 || matches!(
271 self.freshness,
272 ProjectionFreshness::LastKnown
273 | ProjectionFreshness::ReplacedIncarnation
274 | ProjectionFreshness::Unavailable
275 )
276 {
277 self.freshness = ProjectionFreshness::LastKnown;
278 }
279 }
280 }
281
282 fn mark_inventory_absent(&mut self) {
283 self.availability = ProjectionAvailability::Frozen;
284 self.freshness = ProjectionFreshness::Unavailable;
285 }
286
287 fn refresh_integrity(&mut self) {
288 if self.availability == ProjectionAvailability::Frozen {
289 return;
290 }
291 if self.transport_incomplete || !self.incomplete_evidence.is_empty() {
292 self.availability = ProjectionAvailability::Partial;
293 self.freshness = ProjectionFreshness::IncompleteAfterGap;
294 } else if !self.stale_evidence.is_empty() {
295 if self.availability == ProjectionAvailability::Partial {
296 self.availability = ProjectionAvailability::Current;
297 }
298 self.freshness = ProjectionFreshness::Stale;
299 } else {
300 if self.availability == ProjectionAvailability::Partial {
301 self.availability = ProjectionAvailability::Current;
302 }
303 if !matches!(
304 self.availability,
305 ProjectionAvailability::Unknown | ProjectionAvailability::NotObserved
306 ) {
307 self.freshness = ProjectionFreshness::Live;
308 }
309 }
310 }
311
312 fn mark_transport_gap(&mut self) {
313 self.transport_incomplete = true;
314 self.availability = ProjectionAvailability::Partial;
315 self.freshness = ProjectionFreshness::IncompleteAfterGap;
316 self.mark_pending_unknown();
317 }
318
319 fn mark_source_incomplete(&mut self, evidence: ObservationEvidenceV1, unknown_pending: bool) {
320 push_unique_evidence(&mut self.incomplete_evidence, evidence);
321 self.availability = ProjectionAvailability::Partial;
322 self.freshness = ProjectionFreshness::IncompleteAfterGap;
323 if unknown_pending {
324 self.mark_pending_unknown();
325 }
326 }
327
328 fn mark_source_stale(&mut self, evidence: ObservationEvidenceV1) {
329 push_unique_evidence(&mut self.stale_evidence, evidence);
330 self.refresh_integrity();
331 }
332
333 fn mark_pending_unknown(&mut self) {
334 mark_pending_unknown(&mut self.tools);
335 mark_pending_unknown(&mut self.subagents);
336 mark_pending_unknown(&mut self.interactions);
337 mark_pending_unknown(&mut self.owned_processes);
338 }
339
340 fn source_reset(&mut self, evidence: ObservationEvidenceV1) {
341 self.source_capabilities.retain(|entry| entry.evidence != evidence);
342 if self.history.as_ref().map(|history| history.evidence) == Some(evidence) {
343 self.history = None;
344 }
345 self.tools.retain(|entry| entry.evidence != evidence);
346 self.subagents.retain(|entry| entry.evidence != evidence);
347 self.interactions.retain(|entry| entry.evidence != evidence);
348 self.owned_processes.retain(|entry| entry.evidence != evidence);
349 self.files.retain(|entry| entry.evidence != evidence);
350 if self.todos.current.as_ref().map(|todo| todo.evidence) == Some(evidence) {
351 self.todos.current = None;
352 self.todos.previous = None;
353 self.todos.conflict = false;
354 }
355 if self.usage.cumulative_evidence == Some(evidence) {
356 self.usage.last_cumulative = None;
357 self.usage.cumulative_evidence = None;
358 }
359 if self.usage.context_occupancy.map(|snapshot| snapshot.evidence) == Some(evidence) {
360 self.usage.context_occupancy = None;
361 }
362 self.incomplete_evidence.retain(|current| *current != evidence);
363 self.stale_evidence.retain(|current| *current != evidence);
364 self.refresh_integrity();
365 }
366
367 fn push_timeline(&mut self, entry: TimelineEntry) {
368 if self.timeline.len() == TIMELINE_PER_SESSION_MAX {
369 self.timeline.pop_front();
370 }
371 self.timeline.push_back(entry);
372 }
373}
374
375fn push_unique_evidence(
376 entries: &mut Vec<ObservationEvidenceV1>,
377 evidence: ObservationEvidenceV1,
378) {
379 if !entries.contains(&evidence) {
380 entries.push(evidence);
381 }
382}
383
384fn mark_pending_unknown(entries: &mut [CorrelationProjection]) {
385 for entry in entries {
386 if entry.state == CorrelationState::Pending {
387 entry.state = CorrelationState::UnknownAfterGap;
388 }
389 }
390}
391
392#[derive(Clone, Debug, Eq, Hash, PartialEq)]
393struct CursorKey {
394 node_id: NodeId,
395 incarnation_id: NodeIncarnationId,
396 sequence: u64,
397}
398
399pub const OBSERVATION_ENGINE_CHECKPOINT_VERSION_V1: u16 = 1;
400pub const OBSERVATION_ROUTE_HISTORY_MAX: usize =
401 RETIRED_INCARNATIONS_MAX + NODE_ROUTES_MAX;
402const CURSOR_JOURNAL_CHECKPOINT_RETAINED_MAX: usize = 256;
405
406#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
407#[serde(deny_unknown_fields)]
408pub struct ObservationCheckpointCursorV1 {
409 pub node_id: NodeId,
410 pub cursor: NodeCursor,
411 pub payload: ObservationIngressPayload,
412}
413
414#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
415#[serde(deny_unknown_fields)]
416pub struct ObservationCheckpointRouteV1 {
417 pub node_id: NodeId,
418 pub incarnation_id: NodeIncarnationId,
419}
420
421#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
422#[serde(deny_unknown_fields)]
423pub struct ObservationCheckpointRouteCursorV1 {
424 pub node_id: NodeId,
425 pub cursor: NodeCursor,
426}
427
428#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
429#[serde(deny_unknown_fields)]
430pub struct ObservationEngineCheckpointV1 {
431 pub version: u16,
432 pub projections: Vec<SessionProjection>,
433 pub cursor_journal: Vec<ObservationCheckpointCursorV1>,
434 pub current_incarnations: Vec<ObservationCheckpointRouteV1>,
435 pub retired_incarnations: Vec<ObservationCheckpointRouteV1>,
436 pub managed_links: Vec<ManagedRecordLink>,
437 pub transport_gaps: Vec<ObservationCheckpointRouteV1>,
438 pub retention_floors: Vec<ObservationCheckpointRouteCursorV1>,
439 pub high_watermarks: Vec<ObservationCheckpointRouteCursorV1>,
440}
441
442#[derive(Clone, Debug, Default, Eq, PartialEq)]
443pub struct ObservationEngine {
444 projections: HashMap<ObservationTarget, SessionProjection>,
445 journal: HashMap<CursorKey, ObservationIngressEnvelope>,
446 current_incarnations: HashMap<NodeId, NodeIncarnationId>,
447 retired_incarnations: HashSet<(NodeId, NodeIncarnationId)>,
448 managed_links: HashMap<ManagedSessionKey, Option<RuntimeSessionKey>>,
449 transport_gaps: HashSet<(NodeId, NodeIncarnationId)>,
450 retention_floors: HashMap<(NodeId, NodeIncarnationId), u64>,
451 high_watermarks: HashMap<(NodeId, NodeIncarnationId), u64>,
452}
453
454impl ObservationEngine {
455 pub fn new() -> Self {
456 Self::default()
457 }
458
459 pub fn projection(&self, target: &ObservationTarget) -> Option<&SessionProjection> {
460 self.projections.get(target)
461 }
462
463 pub fn projection_count(&self) -> usize {
464 self.projections.len()
465 }
466
467 pub fn projections(&self) -> impl Iterator<Item = (&ObservationTarget, &SessionProjection)> {
468 self.projections.iter()
469 }
470
471 pub fn managed_runtime(&self, key: &ManagedSessionKey) -> Option<&RuntimeSessionKey> {
472 self.managed_links.get(key).and_then(Option::as_ref)
473 }
474
475 pub fn durable_resume_cursors(&self) -> Vec<(NodeId, NodeCursor)> {
476 let mut cursors = self
477 .high_watermarks
478 .iter()
479 .map(|((node_id, incarnation_id), sequence)| {
480 (node_id.clone(), NodeCursor {
481 incarnation_id: *incarnation_id,
482 sequence: *sequence,
483 })
484 })
485 .collect::<Vec<_>>();
486 cursors.sort_by(|left, right| {
487 left.0.cmp(&right.0)
488 .then_with(|| left.1.incarnation_id.cmp(&right.1.incarnation_id))
489 });
490 cursors
491 }
492
493 pub fn checkpoint(&self) -> ObservationEngineCheckpointV1 {
494 self.to_checkpoint()
495 }
496
497 pub fn restore(
498 checkpoint: ObservationEngineCheckpointV1,
499 ) -> Result<Self, ObservationEngineError> {
500 checkpoint.validate()?;
501 let mut engine = Self::new();
502 for projection in checkpoint.projections {
503 engine.projections.insert(projection.target.clone(), projection);
504 }
505 for entry in checkpoint.cursor_journal {
506 let key = CursorKey {
507 node_id: entry.node_id.clone(),
508 incarnation_id: entry.cursor.incarnation_id,
509 sequence: entry.cursor.sequence,
510 };
511 engine.journal.insert(key, ObservationIngressEnvelope {
512 node_id: entry.node_id,
513 cursor: entry.cursor,
514 received_at_ms: 1,
515 transport: hatchery_observation_api::ObservationTransport::DirectNode,
516 payload: entry.payload,
517 });
518 }
519 for route in checkpoint.current_incarnations {
520 engine.current_incarnations.insert(route.node_id, route.incarnation_id);
521 }
522 for route in checkpoint.retired_incarnations {
523 engine.retired_incarnations.insert((route.node_id, route.incarnation_id));
524 }
525 for link in checkpoint.managed_links {
526 engine.managed_links.insert(link.managed, link.runtime);
527 }
528 for route in checkpoint.transport_gaps {
529 engine.transport_gaps.insert((route.node_id, route.incarnation_id));
530 }
531 for route in checkpoint.retention_floors {
532 engine.retention_floors.insert(
533 (route.node_id, route.cursor.incarnation_id),
534 route.cursor.sequence,
535 );
536 }
537 for route in checkpoint.high_watermarks {
538 engine.high_watermarks.insert(
539 (route.node_id, route.cursor.incarnation_id),
540 route.cursor.sequence,
541 );
542 }
543 engine.retire_superseded_projections("checkpoint load");
544 Ok(engine)
545 }
546
547 pub fn prepare(
548 &self,
549 envelope: ObservationIngressEnvelope,
550 ) -> Result<PreparedBatch, ObservationEngineError> {
551 self.prepare_batch(&[envelope])
552 }
553
554 pub fn prepare_batch(
555 &self,
556 envelopes: &[ObservationIngressEnvelope],
557 ) -> Result<PreparedBatch, ObservationEngineError> {
558 if envelopes.len() > INGRESS_BATCH_MAX {
559 return Err(ObservationEngineError::CapacityExhausted {
560 collection: "ingress batch",
561 max: INGRESS_BATCH_MAX,
562 });
563 }
564 let mut next = self.clone();
565 let mut outcomes = Vec::with_capacity(envelopes.len());
566 for envelope in envelopes {
567 outcomes.push(next.apply_envelope(envelope.clone())?);
568 }
569 Ok(PreparedBatch { next, outcomes })
570 }
571
572 pub fn prepare_resync(
573 &self,
574 batch: &ObservationResyncBatch,
575 ) -> Result<PreparedBatch, ObservationEngineError> {
576 batch.validate()?;
577 let mut next = self.clone();
578 next.observe_incarnation(&batch.node_id, batch.incarnation_id)?;
579 next.record_high_watermark(
580 &batch.node_id,
581 batch.incarnation_id,
582 batch.high_watermark.sequence,
583 );
584 next.apply_resync_records(batch)?;
585 let proves_no_eviction = batch.requested_after.saturating_add(1)
586 >= batch.oldest_available_sequence;
587 if proves_no_eviction {
588 next.clear_node_transport_gap(&batch.node_id, batch.incarnation_id);
589 } else {
590 next.mark_node_gap(&batch.node_id, batch.incarnation_id);
591 }
592 let mut outcomes = Vec::with_capacity(batch.events.len());
593 for envelope in &batch.events {
594 outcomes.push(next.apply_envelope(envelope.clone())?);
595 }
596 Ok(PreparedBatch { next, outcomes })
597 }
598
599 pub fn prepare_record_inventory(
606 &self,
607 node_id: &NodeId,
608 incarnation_id: NodeIncarnationId,
609 records: &[ManagedRecordLink],
610 complete: bool,
611 ) -> Result<PreparedBatch, ObservationEngineError> {
612 let inventory = ObservationRecordInventory {
613 node_id: node_id.clone(),
614 incarnation_id,
615 records: records.to_vec(),
616 complete,
617 };
618 inventory.validate()?;
619
620 let mut next = self.clone();
621 next.observe_incarnation(node_id, incarnation_id)?;
622 next.apply_record_inventory(node_id, incarnation_id, records, complete)?;
623 Ok(PreparedBatch {
624 next,
625 outcomes: Vec::new(),
626 })
627 }
628
629 pub fn accept(&mut self, prepared: PreparedBatch) {
631 *self = prepared.next;
632 }
633
634 fn apply_envelope(
635 &mut self,
636 envelope: ObservationIngressEnvelope,
637 ) -> Result<ApplyOutcome, ObservationEngineError> {
638 envelope.validate()?;
639 let cursor_key = CursorKey {
640 node_id: envelope.node_id.clone(),
641 incarnation_id: envelope.cursor.incarnation_id,
642 sequence: envelope.cursor.sequence,
643 };
644 if let Some(existing) = self.journal.get(&cursor_key) {
645 if existing.canonical_eq(&envelope) {
646 return Ok(ApplyOutcome::Duplicate);
647 }
648 return Err(ObservationEngineError::CursorCollision {
649 node_id: envelope.node_id,
650 cursor: envelope.cursor,
651 });
652 }
653 if self
654 .retention_floors
655 .get(&(envelope.node_id.clone(), envelope.cursor.incarnation_id))
656 .is_some_and(|floor| envelope.cursor.sequence <= *floor)
657 {
658 return Err(ObservationEngineError::BelowRetentionFloor {
659 node_id: envelope.node_id,
660 cursor: envelope.cursor,
661 });
662 }
663 if self.journal.len() >= CURSOR_JOURNAL_MAX {
664 self.compact_cursor_journal();
665 }
666
667 self.observe_incarnation(&envelope.node_id, envelope.cursor.incarnation_id)?;
668 self.apply_payload(&envelope)?;
669 self.record_high_watermark(
670 &envelope.node_id,
671 envelope.cursor.incarnation_id,
672 envelope.cursor.sequence,
673 );
674 self.journal.insert(cursor_key, envelope);
675 Ok(ApplyOutcome::Applied)
676 }
677
678 fn observe_incarnation(
679 &mut self,
680 node_id: &NodeId,
681 incarnation_id: NodeIncarnationId,
682 ) -> Result<(), ObservationEngineError> {
683 match self.current_incarnations.get(node_id).copied() {
684 None => {
685 if self.current_incarnations.len() == NODE_ROUTES_MAX {
686 return Err(ObservationEngineError::CapacityExhausted {
687 collection: "node routes",
688 max: NODE_ROUTES_MAX,
689 });
690 }
691 self.current_incarnations.insert(node_id.clone(), incarnation_id);
692 }
693 Some(current) if current == incarnation_id => {}
694 Some(_) if self.retired_incarnations.contains(&(node_id.clone(), incarnation_id)) => {
695 return Err(ObservationEngineError::ReplacedIncarnation {
696 node_id: node_id.clone(),
697 incarnation_id,
698 });
699 }
700 Some(current) => {
701 if self.retired_incarnations.len() == RETIRED_INCARNATIONS_MAX {
702 return Err(ObservationEngineError::CapacityExhausted {
703 collection: "retired incarnations",
704 max: RETIRED_INCARNATIONS_MAX,
705 });
706 }
707 self.retired_incarnations.insert((node_id.clone(), current));
708 for projection in self.projections.values_mut() {
709 let replaced = match &projection.target {
710 ObservationTarget::Runtime { key } => {
711 &key.node_id == node_id && key.incarnation_id == current
712 }
713 ObservationTarget::Managed { key } => {
714 &key.node_id == node_id && key.incarnation_id == current
715 }
716 };
717 if replaced {
718 projection.availability = ProjectionAvailability::Frozen;
719 projection.freshness = ProjectionFreshness::ReplacedIncarnation;
720 }
721 }
722 for (key, runtime) in self.managed_links.iter_mut() {
723 if &key.node_id == node_id && key.incarnation_id == current {
724 *runtime = None;
725 }
726 }
727 self.current_incarnations.insert(node_id.clone(), incarnation_id);
728 }
729 }
730 Ok(())
731 }
732
733 fn apply_payload(
734 &mut self,
735 envelope: &ObservationIngressEnvelope,
736 ) -> Result<(), ObservationEngineError> {
737 match &envelope.payload {
738 ObservationIngressPayload::Observations {
739 address,
740 observations,
741 } => {
742 let mut targets = vec![address.clone()];
746 if let ObservationTarget::Runtime { key } = address {
747 targets.extend(
748 self.managed_links
749 .iter()
750 .filter(|(_, runtime)| runtime.as_ref() == Some(key))
751 .map(|(managed, _)| ObservationTarget::Managed {
752 key: managed.clone(),
753 }),
754 );
755 }
756 for target in targets {
757 let projection = self.ensure_projection(target)?;
758 projection.mark_observed();
759 for observation in observations {
760 apply_observation(
761 projection,
762 envelope.cursor,
763 envelope.received_at_ms,
764 observation,
765 )?;
766 }
767 }
768 }
769 ObservationIngressPayload::ManagedRecordUpserted { link } => {
770 self.upsert_managed_link(link.clone())?;
771 }
772 ObservationIngressPayload::ManagedRecordRemoved { key } => {
773 self.remove_managed_link(key);
774 }
775 ObservationIngressPayload::ResyncRequired { .. } => {
776 self.mark_node_gap(&envelope.node_id, envelope.cursor.incarnation_id);
777 }
778 ObservationIngressPayload::CursorOnly => {}
779 }
780 Ok(())
781 }
782
783 fn projection_is_superseded(&self, target: &ObservationTarget) -> bool {
793 match self.current_incarnations.get(target.node_id()) {
794 Some(current) => *current != target.incarnation_id(),
795 None => false,
796 }
797 }
798
799 fn retire_superseded_projections(&mut self, reason: &'static str) -> usize {
811 let dead = self
812 .projections
813 .keys()
814 .filter(|target| self.projection_is_superseded(target))
815 .cloned()
816 .collect::<Vec<_>>();
817 let retired = dead.len();
818 for target in &dead {
819 self.projections.remove(target);
820 }
821 tracing::info!(
822 retired,
823 remaining = self.projections.len(),
824 reason,
825 criterion = "target incarnation superseded by the node's current incarnation",
826 "session projection retirement pass completed",
827 );
828 retired
829 }
830
831 fn ensure_projection(
832 &mut self,
833 target: ObservationTarget,
834 ) -> Result<&mut SessionProjection, ObservationEngineError> {
835 if !self.projections.contains_key(&target) {
836 if self.projections.len() == PROJECTIONS_MAX {
837 self.retire_superseded_projections("capacity pressure");
838 if self.projections.len() == PROJECTIONS_MAX {
839 return Err(ObservationEngineError::CapacityExhausted {
840 collection: "session projections",
841 max: PROJECTIONS_MAX,
842 });
843 }
844 }
845 let mut projection = SessionProjection::new(target.clone());
846 if self.transport_gaps.contains(&(
847 target.node_id().clone(),
848 target.incarnation_id(),
849 )) {
850 projection.mark_transport_gap();
851 }
852 self.projections.insert(target.clone(), projection);
853 }
854 Ok(self.projections.get_mut(&target).expect("projection was inserted"))
855 }
856
857 fn upsert_managed_link(
858 &mut self,
859 link: ManagedRecordLink,
860 ) -> Result<(), ObservationEngineError> {
861 let target = ObservationTarget::Managed {
862 key: link.managed.clone(),
863 };
864 let projection = self.ensure_projection(target)?;
865 projection.mark_inventory_present(link.runtime.is_some());
866 self.managed_links.insert(link.managed, link.runtime);
867 Ok(())
868 }
869
870 fn remove_managed_link(&mut self, key: &ManagedSessionKey) {
871 self.managed_links.remove(key);
872 let target = ObservationTarget::Managed { key: key.clone() };
873 if let Some(projection) = self.projections.get_mut(&target) {
874 projection.mark_inventory_absent();
875 }
876 }
877
878 fn apply_resync_records(
879 &mut self,
880 batch: &ObservationResyncBatch,
881 ) -> Result<(), ObservationEngineError> {
882 self.apply_record_inventory(
883 &batch.node_id,
884 batch.incarnation_id,
885 &batch.records,
886 batch.records_complete,
887 )
888 }
889
890 fn apply_record_inventory(
891 &mut self,
892 node_id: &NodeId,
893 incarnation_id: NodeIncarnationId,
894 records: &[ManagedRecordLink],
895 complete: bool,
896 ) -> Result<(), ObservationEngineError> {
897 let present: HashSet<_> = records.iter().map(|link| link.managed.clone()).collect();
898 if complete {
899 let absent: Vec<_> = self
900 .projections
901 .keys()
902 .filter_map(|target| match target {
903 ObservationTarget::Managed { key }
904 if &key.node_id == node_id
905 && key.incarnation_id == incarnation_id
906 && !present.contains(key) => Some(key.clone()),
907 _ => None,
908 })
909 .collect();
910 for key in absent {
911 self.managed_links.remove(&key);
912 let target = ObservationTarget::Managed { key: key.clone() };
913 if let Some(projection) = self.projections.get_mut(&target) {
914 projection.mark_inventory_absent();
915 }
916 }
917 }
918 for link in records {
919 self.upsert_managed_link(link.clone())?;
920 }
921 Ok(())
922 }
923
924 fn mark_node_gap(&mut self, node_id: &NodeId, incarnation_id: NodeIncarnationId) {
925 self.transport_gaps.insert((node_id.clone(), incarnation_id));
926 for projection in self.projections.values_mut() {
927 let belongs = match &projection.target {
928 ObservationTarget::Runtime { key } => {
929 &key.node_id == node_id && key.incarnation_id == incarnation_id
930 }
931 ObservationTarget::Managed { key } => {
932 &key.node_id == node_id
933 && key.incarnation_id == incarnation_id
934 }
935 };
936 if belongs && projection.availability != ProjectionAvailability::Frozen {
937 projection.mark_transport_gap();
938 }
939 }
940 }
941
942 fn clear_node_transport_gap(&mut self, node_id: &NodeId, incarnation_id: NodeIncarnationId) {
943 self.transport_gaps.remove(&(node_id.clone(), incarnation_id));
944 for projection in self.projections.values_mut() {
945 let belongs = match &projection.target {
946 ObservationTarget::Runtime { key } => {
947 &key.node_id == node_id && key.incarnation_id == incarnation_id
948 }
949 ObservationTarget::Managed { key } => {
950 &key.node_id == node_id
951 && key.incarnation_id == incarnation_id
952 }
953 };
954 if belongs {
955 projection.transport_incomplete = false;
956 projection.refresh_integrity();
957 }
958 }
959 }
960
961 fn record_high_watermark(
962 &mut self,
963 node_id: &NodeId,
964 incarnation_id: NodeIncarnationId,
965 sequence: u64,
966 ) {
967 let high = self
968 .high_watermarks
969 .entry((node_id.clone(), incarnation_id))
970 .or_insert(0);
971 *high = (*high).max(sequence);
972 }
973
974 fn compact_cursor_journal(&mut self) {
975 if self.journal.len() <= CURSOR_JOURNAL_CHECKPOINT_RETAINED_MAX {
976 return;
977 }
978 let evicted_count = self.journal.len() - CURSOR_JOURNAL_CHECKPOINT_RETAINED_MAX;
979 let mut keys = self.journal.keys().cloned().collect::<Vec<_>>();
980 keys.sort_by(|left, right| {
981 left.sequence.cmp(&right.sequence)
982 .then_with(|| left.node_id.cmp(&right.node_id))
983 .then_with(|| left.incarnation_id.cmp(&right.incarnation_id))
984 });
985 for key in keys.into_iter().take(evicted_count) {
986 let floor = self
987 .retention_floors
988 .entry((key.node_id.clone(), key.incarnation_id))
989 .or_insert(0);
990 *floor = (*floor).max(key.sequence);
991 self.journal.remove(&key);
992 }
993 }
994
995 fn to_checkpoint(&self) -> ObservationEngineCheckpointV1 {
996 let mut projections = self.projections.values().cloned().collect::<Vec<_>>();
997 projections.sort_by(|left, right| target_sort_key(&left.target).cmp(&target_sort_key(&right.target)));
998
999 let mut cursor_journal = self.journal.values().map(|entry| {
1000 ObservationCheckpointCursorV1 {
1001 node_id: entry.node_id.clone(),
1002 cursor: entry.cursor,
1003 payload: entry.payload.clone(),
1004 }
1005 }).collect::<Vec<_>>();
1006 cursor_journal.sort_by(route_cursor_order);
1007
1008 let mut current_incarnations = self.current_incarnations.iter().map(|(node_id, incarnation_id)| {
1009 ObservationCheckpointRouteV1 { node_id: node_id.clone(), incarnation_id: *incarnation_id }
1010 }).collect::<Vec<_>>();
1011 current_incarnations.sort_by(route_order);
1012 let mut retired_incarnations = self.retired_incarnations.iter().map(|(node_id, incarnation_id)| {
1013 ObservationCheckpointRouteV1 { node_id: node_id.clone(), incarnation_id: *incarnation_id }
1014 }).collect::<Vec<_>>();
1015 retired_incarnations.sort_by(route_order);
1016 let mut managed_links = self.managed_links.iter().map(|(managed, runtime)| ManagedRecordLink {
1017 managed: managed.clone(), runtime: runtime.clone(),
1018 }).collect::<Vec<_>>();
1019 managed_links.sort_by(|left, right| {
1020 left.managed.node_id.cmp(&right.managed.node_id)
1021 .then_with(|| left.managed.incarnation_id.cmp(&right.managed.incarnation_id))
1022 .then_with(|| left.managed.record_id.cmp(&right.managed.record_id))
1023 });
1024 let mut transport_gaps = self.transport_gaps.iter().map(|(node_id, incarnation_id)| {
1025 ObservationCheckpointRouteV1 { node_id: node_id.clone(), incarnation_id: *incarnation_id }
1026 }).collect::<Vec<_>>();
1027 transport_gaps.sort_by(route_order);
1028 let mut retention_floors = route_cursor_entries(&self.retention_floors);
1029 retention_floors.sort_by(route_checkpoint_cursor_order);
1030 let mut high_watermarks = route_cursor_entries(&self.high_watermarks);
1031 high_watermarks.sort_by(route_checkpoint_cursor_order);
1032
1033 ObservationEngineCheckpointV1 {
1034 version: OBSERVATION_ENGINE_CHECKPOINT_VERSION_V1,
1035 projections,
1036 cursor_journal,
1037 current_incarnations,
1038 retired_incarnations,
1039 managed_links,
1040 transport_gaps,
1041 retention_floors,
1042 high_watermarks,
1043 }
1044 }
1045}
1046
1047#[derive(Clone, Debug)]
1048pub struct PreparedBatch {
1049 next: ObservationEngine,
1050 outcomes: Vec<ApplyOutcome>,
1051}
1052
1053impl PreparedBatch {
1054 pub fn outcomes(&self) -> &[ApplyOutcome] {
1055 &self.outcomes
1056 }
1057
1058 pub fn compact_for_checkpoint(&mut self) -> ObservationEngineCheckpointV1 {
1059 self.next.compact_cursor_journal();
1060 self.next.to_checkpoint()
1061 }
1062
1063 pub fn checkpoint(&self) -> ObservationEngineCheckpointV1 {
1064 self.next.to_checkpoint()
1065 }
1066}
1067
1068impl ObservationEngineCheckpointV1 {
1069 pub fn validate(&self) -> Result<(), ObservationEngineError> {
1070 if self.version != OBSERVATION_ENGINE_CHECKPOINT_VERSION_V1 {
1071 return Err(ObservationEngineError::InvalidCheckpoint {
1072 detail: "unsupported checkpoint version",
1073 });
1074 }
1075 if self.projections.len() > PROJECTIONS_MAX
1076 || self.cursor_journal.len() > CURSOR_JOURNAL_MAX
1077 || self.current_incarnations.len() > NODE_ROUTES_MAX
1078 || self.retired_incarnations.len() > RETIRED_INCARNATIONS_MAX
1079 || self.managed_links.len() > PROJECTIONS_MAX
1080 || self.transport_gaps.len() > OBSERVATION_ROUTE_HISTORY_MAX
1081 || self.retention_floors.len() > OBSERVATION_ROUTE_HISTORY_MAX
1082 || self.high_watermarks.len() > OBSERVATION_ROUTE_HISTORY_MAX
1083 {
1084 return Err(ObservationEngineError::InvalidCheckpoint {
1085 detail: "checkpoint collection exceeds its bound",
1086 });
1087 }
1088
1089 let mut projection_targets = HashSet::new();
1090 for projection in &self.projections {
1091 projection.target.validate_route(
1092 projection.target.node_id(),
1093 projection.target.incarnation_id(),
1094 )?;
1095 if projection.timeline.len() > TIMELINE_PER_SESSION_MAX
1096 || projection.tools.len() > TOOLS_PER_SESSION_MAX
1097 || projection.subagents.len() > SUBAGENTS_PER_SESSION_MAX
1098 || projection.files.len() > FILES_PER_SESSION_MAX
1099 || projection.owned_processes.len() > OWNED_PROCESSES_PER_SESSION_MAX
1100 || projection.interactions.len() > INTERACTIONS_PER_SESSION_MAX
1101 || !projection_targets.insert(projection.target.clone())
1102 {
1103 return Err(ObservationEngineError::InvalidCheckpoint {
1104 detail: "invalid or duplicate session projection",
1105 });
1106 }
1107 }
1108
1109 let mut journal_keys = HashSet::new();
1110 for entry in &self.cursor_journal {
1111 if entry.cursor.sequence == 0 {
1112 return Err(ObservationEngineError::InvalidCheckpoint {
1113 detail: "zero checkpoint cursor",
1114 });
1115 }
1116 entry.payload.validate_route(&entry.node_id, entry.cursor)?;
1117 let key = (entry.node_id.clone(), entry.cursor.incarnation_id, entry.cursor.sequence);
1118 if !journal_keys.insert(key) {
1119 return Err(ObservationEngineError::InvalidCheckpoint {
1120 detail: "duplicate checkpoint cursor",
1121 });
1122 }
1123 }
1124
1125 validate_unique_routes(&self.current_incarnations)?;
1126 validate_unique_routes(&self.retired_incarnations)?;
1127 validate_unique_routes(&self.transport_gaps)?;
1128 validate_unique_route_cursors(&self.retention_floors, false)?;
1129 validate_unique_route_cursors(&self.high_watermarks, true)?;
1130 let mut current_nodes = HashSet::new();
1131 if self.current_incarnations.iter().any(|route| {
1132 !current_nodes.insert(route.node_id.clone())
1133 || self.retired_incarnations.iter().any(|retired| {
1134 retired.node_id == route.node_id
1135 && retired.incarnation_id == route.incarnation_id
1136 })
1137 }) {
1138 return Err(ObservationEngineError::InvalidCheckpoint {
1139 detail: "current incarnation routes conflict",
1140 });
1141 }
1142
1143 let mut managed_keys = HashSet::new();
1144 for link in &self.managed_links {
1145 link.validate_route(&link.managed.node_id, link.managed.incarnation_id)?;
1146 if !managed_keys.insert(link.managed.clone()) {
1147 return Err(ObservationEngineError::InvalidCheckpoint {
1148 detail: "duplicate managed link",
1149 });
1150 }
1151 }
1152
1153 for floor in &self.retention_floors {
1154 let high = self.high_watermarks.iter().find(|high| {
1155 high.node_id == floor.node_id
1156 && high.cursor.incarnation_id == floor.cursor.incarnation_id
1157 });
1158 if floor.cursor.sequence == 0
1159 || high.is_none_or(|high| floor.cursor.sequence > high.cursor.sequence)
1160 {
1161 return Err(ObservationEngineError::InvalidCheckpoint {
1162 detail: "retention floor exceeds durable high watermark",
1163 });
1164 }
1165 }
1166 for entry in &self.cursor_journal {
1167 let high = self.high_watermarks.iter().find(|high| {
1168 high.node_id == entry.node_id
1169 && high.cursor.incarnation_id == entry.cursor.incarnation_id
1170 });
1171 let floor = self.retention_floors.iter().find(|floor| {
1172 floor.node_id == entry.node_id
1173 && floor.cursor.incarnation_id == entry.cursor.incarnation_id
1174 });
1175 if high.is_none_or(|high| entry.cursor.sequence > high.cursor.sequence)
1176 || floor.is_some_and(|floor| entry.cursor.sequence <= floor.cursor.sequence)
1177 {
1178 return Err(ObservationEngineError::InvalidCheckpoint {
1179 detail: "cursor journal is outside durable route bounds",
1180 });
1181 }
1182 }
1183 Ok(())
1184 }
1185}
1186
1187fn validate_unique_routes(
1188 routes: &[ObservationCheckpointRouteV1],
1189) -> Result<(), ObservationEngineError> {
1190 let mut unique = HashSet::new();
1191 if routes.iter().any(|route| !unique.insert((route.node_id.clone(), route.incarnation_id))) {
1192 return Err(ObservationEngineError::InvalidCheckpoint {
1193 detail: "duplicate checkpoint route",
1194 });
1195 }
1196 Ok(())
1197}
1198
1199fn validate_unique_route_cursors(
1200 routes: &[ObservationCheckpointRouteCursorV1],
1201 allow_zero: bool,
1202) -> Result<(), ObservationEngineError> {
1203 let mut unique = HashSet::new();
1204 if routes.iter().any(|route| {
1205 (!allow_zero && route.cursor.sequence == 0)
1206 || !unique.insert((route.node_id.clone(), route.cursor.incarnation_id))
1207 }) {
1208 return Err(ObservationEngineError::InvalidCheckpoint {
1209 detail: "invalid or duplicate checkpoint route cursor",
1210 });
1211 }
1212 Ok(())
1213}
1214
1215fn target_sort_key(target: &ObservationTarget) -> (NodeId, NodeIncarnationId, String) {
1216 match target {
1217 ObservationTarget::Runtime { key } => (
1218 key.node_id.clone(),
1219 key.incarnation_id,
1220 format!("runtime:{}:{}:{}", key.workspace_id, key.instance_id.0, key.generation.0),
1221 ),
1222 ObservationTarget::Managed { key } => (
1223 key.node_id.clone(),
1224 key.incarnation_id,
1225 format!("managed:{}", key.record_id),
1226 ),
1227 }
1228}
1229
1230fn route_order(
1231 left: &ObservationCheckpointRouteV1,
1232 right: &ObservationCheckpointRouteV1,
1233) -> std::cmp::Ordering {
1234 left.node_id.cmp(&right.node_id)
1235 .then_with(|| left.incarnation_id.cmp(&right.incarnation_id))
1236}
1237
1238fn route_cursor_order(
1239 left: &ObservationCheckpointCursorV1,
1240 right: &ObservationCheckpointCursorV1,
1241) -> std::cmp::Ordering {
1242 left.node_id.cmp(&right.node_id)
1243 .then_with(|| left.cursor.incarnation_id.cmp(&right.cursor.incarnation_id))
1244 .then_with(|| left.cursor.sequence.cmp(&right.cursor.sequence))
1245}
1246
1247fn route_checkpoint_cursor_order(
1248 left: &ObservationCheckpointRouteCursorV1,
1249 right: &ObservationCheckpointRouteCursorV1,
1250) -> std::cmp::Ordering {
1251 left.node_id.cmp(&right.node_id)
1252 .then_with(|| left.cursor.incarnation_id.cmp(&right.cursor.incarnation_id))
1253}
1254
1255fn route_cursor_entries(
1256 entries: &HashMap<(NodeId, NodeIncarnationId), u64>,
1257) -> Vec<ObservationCheckpointRouteCursorV1> {
1258 entries.iter().map(|((node_id, incarnation_id), sequence)| {
1259 ObservationCheckpointRouteCursorV1 {
1260 node_id: node_id.clone(),
1261 cursor: NodeCursor {
1262 incarnation_id: *incarnation_id,
1263 sequence: *sequence,
1264 },
1265 }
1266 }).collect()
1267}
1268
1269fn apply_observation(
1270 projection: &mut SessionProjection,
1271 cursor: NodeCursor,
1272 received_at_ms: u64,
1273 observation: &hatchery_observation_protocol::ObservationV1,
1274) -> Result<(), ObservationEngineError> {
1275 let evidence = observation.evidence;
1276 match &observation.kind {
1277 ObservationKindV1::SourceCapabilities {
1278 source_family,
1279 source_adapter,
1280 capabilities,
1281 } => {
1282 if let Some(current) = projection.source_capabilities.iter_mut().find(|current| {
1283 current.evidence == evidence
1284 && current.source_family == *source_family
1285 && current.source_adapter == *source_adapter
1286 }) {
1287 current.capabilities = *capabilities;
1288 } else {
1289 if projection.source_capabilities.len() == hatchery_observation_protocol::OBSERVATION_COLLECTION_MAX {
1290 return Err(ObservationEngineError::CapacityExhausted {
1291 collection: "source capabilities",
1292 max: hatchery_observation_protocol::OBSERVATION_COLLECTION_MAX,
1293 });
1294 }
1295 projection.source_capabilities.push(SourceCapabilitiesProjection {
1296 evidence,
1297 source_family: *source_family,
1298 source_adapter: source_adapter.clone(),
1299 capabilities: *capabilities,
1300 });
1301 }
1302 }
1303 ObservationKindV1::Stopped | ObservationKindV1::Exited { .. } => {
1304 projection.freshness = ProjectionFreshness::LastKnown;
1305 }
1306 ObservationKindV1::Stale => projection.mark_source_stale(evidence),
1307 ObservationKindV1::Error { .. }
1308 if evidence == ObservationEvidenceV1::HistoryProjection =>
1309 {
1310 projection.mark_source_stale(evidence)
1311 }
1312 ObservationKindV1::ToolStarted {
1313 correlation_id,
1314 class,
1315 } => start_correlation(
1316 &mut projection.tools,
1317 correlation_id,
1318 Some(class),
1319 evidence,
1320 TOOLS_PER_SESSION_MAX,
1321 "tools",
1322 )?,
1323 ObservationKindV1::ToolCompleted {
1324 correlation_id,
1325 class,
1326 success,
1327 ..
1328 } => complete_correlation(
1329 &mut projection.tools,
1330 correlation_id,
1331 Some(class),
1332 evidence,
1333 Some(*success),
1334 TOOLS_PER_SESSION_MAX,
1335 "tools",
1336 )?,
1337 ObservationKindV1::ApprovalRequested {
1338 correlation_id,
1339 tool_class,
1340 }
1341 | ObservationKindV1::QuestionRequested {
1342 correlation_id,
1343 tool_class,
1344 } => start_correlation(
1345 &mut projection.interactions,
1346 correlation_id,
1347 Some(tool_class),
1348 evidence,
1349 INTERACTIONS_PER_SESSION_MAX,
1350 "interactions",
1351 )?,
1352 ObservationKindV1::ApprovalResolved {
1353 correlation_id,
1354 outcome,
1355 }
1356 | ObservationKindV1::QuestionResolved {
1357 correlation_id,
1358 outcome,
1359 }
1360 | ObservationKindV1::InteractionResolved {
1361 correlation_id,
1362 outcome,
1363 } => resolve_correlation(
1364 &mut projection.interactions,
1365 correlation_id,
1366 evidence,
1367 *outcome,
1368 INTERACTIONS_PER_SESSION_MAX,
1369 "interactions",
1370 )?,
1371 ObservationKindV1::SubagentStarted {
1372 correlation_id,
1373 class,
1374 } => start_correlation(
1375 &mut projection.subagents,
1376 correlation_id,
1377 Some(class),
1378 evidence,
1379 SUBAGENTS_PER_SESSION_MAX,
1380 "subagents",
1381 )?,
1382 ObservationKindV1::SubagentProgress { correlation_id } => {
1383 start_correlation(
1384 &mut projection.subagents,
1385 correlation_id,
1386 None,
1387 evidence,
1388 SUBAGENTS_PER_SESSION_MAX,
1389 "subagents",
1390 )?;
1391 }
1392 ObservationKindV1::SubagentCompleted {
1393 correlation_id,
1394 success,
1395 } => complete_correlation(
1396 &mut projection.subagents,
1397 correlation_id,
1398 None,
1399 evidence,
1400 *success,
1401 SUBAGENTS_PER_SESSION_MAX,
1402 "subagents",
1403 )?,
1404 ObservationKindV1::OwnedProcessStarted {
1405 correlation_id,
1406 class,
1407 } => start_correlation(
1408 &mut projection.owned_processes,
1409 correlation_id,
1410 Some(class),
1411 evidence,
1412 OWNED_PROCESSES_PER_SESSION_MAX,
1413 "owned processes",
1414 )?,
1415 ObservationKindV1::OwnedProcessExited {
1416 correlation_id,
1417 success,
1418 ..
1419 } => complete_correlation(
1420 &mut projection.owned_processes,
1421 correlation_id,
1422 None,
1423 evidence,
1424 *success,
1425 OWNED_PROCESSES_PER_SESSION_MAX,
1426 "owned processes",
1427 )?,
1428 ObservationKindV1::TodoSnapshot {
1429 revision,
1430 items,
1431 complete,
1432 } => apply_todo(projection, *revision, items, *complete, evidence),
1433 ObservationKindV1::Usage {
1434 input_tokens,
1435 output_tokens,
1436 cache_read_tokens,
1437 cache_write_tokens,
1438 reasoning_tokens,
1439 context_window,
1440 is_cumulative,
1441 } => apply_usage(
1442 &mut projection.usage,
1443 UsageTotals {
1444 input_tokens: *input_tokens,
1445 output_tokens: *output_tokens,
1446 cache_read_tokens: *cache_read_tokens,
1447 cache_write_tokens: *cache_write_tokens,
1448 reasoning_tokens: *reasoning_tokens,
1449 },
1450 *context_window,
1451 *is_cumulative,
1452 evidence,
1453 ),
1454 ObservationKindV1::ContextWindowUsage {
1455 uncached_input_tokens,
1456 cache_read_tokens,
1457 cache_write_tokens,
1458 output_tokens,
1459 unattributed_tokens,
1460 used_tokens,
1461 capacity_tokens,
1462 } => apply_context_window_usage(
1463 &mut projection.usage,
1464 *uncached_input_tokens,
1465 *cache_read_tokens,
1466 *cache_write_tokens,
1467 *output_tokens,
1468 *unattributed_tokens,
1469 *used_tokens,
1470 *capacity_tokens,
1471 evidence,
1472 ),
1473 ObservationKindV1::FileChanged { path } => {
1474 if projection.files.len() == FILES_PER_SESSION_MAX {
1475 projection.files.pop_front();
1476 }
1477 projection.files.push_back(FileProjection {
1478 path: path.clone(),
1479 evidence,
1480 });
1481 }
1482 ObservationKindV1::HistorySnapshot {
1483 message_count,
1484 message_count_exact,
1485 completed_turn_count,
1486 total_tokens,
1487 } => {
1488 projection.history = Some(HistoryProjection {
1489 evidence,
1490 message_count: *message_count,
1491 message_count_exact: *message_count_exact,
1492 completed_turn_count: *completed_turn_count,
1493 total_tokens: *total_tokens,
1494 });
1495 projection.stale_evidence.retain(|current| *current != evidence);
1496 projection.refresh_integrity();
1497 }
1498 ObservationKindV1::Gap { .. } => projection.mark_source_incomplete(evidence, true),
1499 ObservationKindV1::SourceReset => projection.source_reset(evidence),
1500 ObservationKindV1::ActionBlocked { .. } => {
1501 projection.blocked_count = projection.blocked_count.saturating_add(1);
1502 projection.last_blocked_at_ms = Some(match projection.last_blocked_at_ms {
1503 Some(previous) => previous.max(received_at_ms),
1504 None => received_at_ms,
1505 });
1506 }
1507 _ => {}
1508 }
1509 if observation.truncated {
1510 projection.mark_source_incomplete(evidence, false);
1511 }
1512 if !matches!(observation.kind, ObservationKindV1::SourceCapabilities { .. }) {
1513 projection.push_timeline(TimelineEntry {
1514 cursor,
1515 received_at_ms,
1516 evidence,
1517 kind: observation.kind.clone(),
1518 });
1519 }
1520 Ok(())
1521}
1522
1523fn start_correlation(
1524 entries: &mut Vec<CorrelationProjection>,
1525 correlation_id: &str,
1526 class: Option<&String>,
1527 evidence: ObservationEvidenceV1,
1528 max: usize,
1529 collection: &'static str,
1530) -> Result<(), ObservationEngineError> {
1531 if let Some(existing) = entries
1532 .iter_mut()
1533 .find(|entry| entry.correlation_id == correlation_id)
1534 {
1535 if existing.state == CorrelationState::Pending && existing.class.is_none() {
1536 existing.class = class.cloned();
1537 }
1538 return Ok(());
1539 }
1540 ensure_correlation_capacity(entries, max, collection)?;
1541 entries.push(CorrelationProjection {
1542 correlation_id: correlation_id.to_owned(),
1543 class: class.cloned(),
1544 evidence,
1545 state: CorrelationState::Pending,
1546 });
1547 Ok(())
1548}
1549
1550fn complete_correlation(
1551 entries: &mut Vec<CorrelationProjection>,
1552 correlation_id: &str,
1553 class: Option<&String>,
1554 evidence: ObservationEvidenceV1,
1555 success: Option<bool>,
1556 max: usize,
1557 collection: &'static str,
1558) -> Result<(), ObservationEngineError> {
1559 if let Some(existing) = entries
1560 .iter_mut()
1561 .find(|entry| entry.correlation_id == correlation_id)
1562 {
1563 if existing.state == CorrelationState::Pending {
1564 existing.state = CorrelationState::Completed { success };
1565 if existing.class.is_none() {
1566 existing.class = class.cloned();
1567 }
1568 }
1569 return Ok(());
1570 }
1571 ensure_correlation_capacity(entries, max, collection)?;
1572 entries.push(CorrelationProjection {
1573 correlation_id: correlation_id.to_owned(),
1574 class: class.cloned(),
1575 evidence,
1576 state: CorrelationState::OrphanCompletion { success },
1577 });
1578 Ok(())
1579}
1580
1581fn resolve_correlation(
1582 entries: &mut Vec<CorrelationProjection>,
1583 correlation_id: &str,
1584 evidence: ObservationEvidenceV1,
1585 outcome: ObservationInteractionOutcomeV1,
1586 max: usize,
1587 collection: &'static str,
1588) -> Result<(), ObservationEngineError> {
1589 if let Some(existing) = entries
1590 .iter_mut()
1591 .find(|entry| entry.correlation_id == correlation_id)
1592 {
1593 if existing.state == CorrelationState::Pending {
1594 existing.state = CorrelationState::Resolved { outcome };
1595 }
1596 return Ok(());
1597 }
1598 ensure_correlation_capacity(entries, max, collection)?;
1599 entries.push(CorrelationProjection {
1600 correlation_id: correlation_id.to_owned(),
1601 class: None,
1602 evidence,
1603 state: CorrelationState::OrphanResolution { outcome },
1604 });
1605 Ok(())
1606}
1607
1608fn ensure_correlation_capacity(
1609 entries: &mut Vec<CorrelationProjection>,
1610 max: usize,
1611 collection: &'static str,
1612) -> Result<(), ObservationEngineError> {
1613 if entries.len() < max {
1614 return Ok(());
1615 }
1616 if let Some(index) = entries.iter().position(|entry| !entry.state.is_active()) {
1617 entries.remove(index);
1618 return Ok(());
1619 }
1620 Err(ObservationEngineError::CapacityExhausted { collection, max })
1621}
1622
1623fn apply_todo(
1624 projection: &mut SessionProjection,
1625 revision: u64,
1626 items: &[ObservationTodoItemV1],
1627 complete: bool,
1628 evidence: ObservationEvidenceV1,
1629) {
1630 let incoming = TodoRevisionProjection {
1631 revision,
1632 items: items.to_vec(),
1633 complete,
1634 evidence,
1635 };
1636 match projection.todos.current.as_ref() {
1637 None => projection.todos.current = Some(incoming),
1638 Some(current) if revision > current.revision => {
1639 projection.todos.previous = projection.todos.current.replace(incoming);
1640 projection.todos.conflict = false;
1641 }
1642 Some(current) if revision == current.revision && current == &incoming => {}
1643 Some(current) if revision == current.revision => {
1644 projection.todos.conflict = true;
1645 projection.mark_source_incomplete(evidence, false);
1646 }
1647 Some(_) => {
1648 projection.todos.conflict = true;
1649 projection.mark_source_incomplete(evidence, false);
1650 }
1651 }
1652}
1653
1654fn apply_usage(
1655 projection: &mut UsageProjection,
1656 incoming: UsageTotals,
1657 context_window: Option<u64>,
1658 is_cumulative: bool,
1659 evidence: ObservationEvidenceV1,
1660) {
1661 projection.context_window = context_window.or(projection.context_window);
1662 let previous = projection
1663 .context_occupancy
1664 .filter(|snapshot| snapshot.evidence == evidence);
1665 let occupancy_totals = if is_cumulative {
1666 incoming
1667 } else {
1668 UsageTotals {
1669 input_tokens: incoming.input_tokens,
1670 output_tokens: previous
1671 .and_then(|snapshot| snapshot.output_tokens)
1672 .unwrap_or(0)
1673 .saturating_add(incoming.output_tokens),
1674 cache_read_tokens: incoming.cache_read_tokens,
1675 cache_write_tokens: incoming.cache_write_tokens,
1676 reasoning_tokens: incoming.reasoning_tokens,
1677 }
1678 };
1679 projection.context_occupancy = Some(ContextOccupancySnapshot {
1680 uncached_input_tokens: Some(occupancy_totals.input_tokens),
1681 output_tokens: Some(occupancy_totals.output_tokens),
1682 cache_read_tokens: Some(occupancy_totals.cache_read_tokens),
1683 cache_write_tokens: Some(occupancy_totals.cache_write_tokens),
1684 reasoning_tokens: Some(occupancy_totals.reasoning_tokens),
1685 unattributed_tokens: Some(0),
1686 used_tokens: occupancy_totals
1687 .input_tokens
1688 .saturating_add(occupancy_totals.cache_read_tokens)
1689 .saturating_add(occupancy_totals.cache_write_tokens)
1690 .saturating_add(occupancy_totals.output_tokens),
1691 context_window: context_window.or_else(|| previous.and_then(|snapshot| snapshot.context_window)),
1692 evidence,
1693 provenance: if is_cumulative {
1694 ContextOccupancyProvenance::CumulativeUsage
1695 } else {
1696 ContextOccupancyProvenance::PerTurnUsageSynthesis
1697 },
1698 });
1699 if !is_cumulative {
1700 projection.observed_delta = projection.observed_delta.saturating_add(incoming);
1701 return;
1702 }
1703 if projection.cumulative_evidence == Some(evidence) {
1704 if let Some(previous) = projection.last_cumulative {
1705 if let Some(delta) = incoming.checked_delta(previous) {
1706 projection.observed_delta = projection.observed_delta.saturating_add(delta);
1707 }
1708 }
1709 }
1710 projection.last_cumulative = Some(incoming);
1711 projection.cumulative_evidence = Some(evidence);
1712}
1713
1714fn apply_context_window_usage(
1715 projection: &mut UsageProjection,
1716 uncached_input_tokens: Option<u64>,
1717 cache_read_tokens: Option<u64>,
1718 cache_write_tokens: Option<u64>,
1719 output_tokens: Option<u64>,
1720 unattributed_tokens: Option<u64>,
1721 used_tokens: u64,
1722 capacity_tokens: u64,
1723 evidence: ObservationEvidenceV1,
1724) {
1725 if evidence == ObservationEvidenceV1::StructuredProvider {
1726 projection.context_occupancy = Some(ContextOccupancySnapshot {
1727 uncached_input_tokens,
1728 output_tokens,
1729 cache_read_tokens,
1730 cache_write_tokens,
1731 reasoning_tokens: None,
1732 unattributed_tokens,
1733 used_tokens,
1734 context_window: Some(capacity_tokens),
1735 evidence,
1736 provenance: ContextOccupancyProvenance::ExactCurrentWindow,
1737 });
1738 } else if projection.context_occupancy.is_some_and(|snapshot| {
1739 snapshot.provenance == ContextOccupancyProvenance::ExactCurrentWindow
1740 }) {
1741 projection.context_occupancy = None;
1742 }
1743}
1744
1745#[cfg(test)]
1746mod context_occupancy_internal_tests {
1747 use super::*;
1748
1749 fn apply_exact(projection: &mut UsageProjection, evidence: ObservationEvidenceV1) {
1750 apply_context_window_usage(
1751 projection,
1752 Some(10),
1753 Some(20),
1754 Some(5),
1755 Some(15),
1756 Some(10),
1757 60,
1758 100,
1759 evidence,
1760 );
1761 }
1762
1763 #[test]
1764 fn non_structured_exact_context_cannot_activate_or_survive_in_reducer() {
1765 let mut projection = UsageProjection {
1766 observed_delta: UsageTotals {
1767 input_tokens: 3,
1768 output_tokens: 2,
1769 ..UsageTotals::default()
1770 },
1771 ..UsageProjection::default()
1772 };
1773 let observed_delta = projection.observed_delta;
1774 for evidence in [
1775 ObservationEvidenceV1::ManagedHook,
1776 ObservationEvidenceV1::NodeLifecycle,
1777 ObservationEvidenceV1::WorkspaceObservation,
1778 ObservationEvidenceV1::HistoryProjection,
1779 ObservationEvidenceV1::PtyHint,
1780 ] {
1781 apply_exact(&mut projection, evidence);
1782 assert!(projection.context_occupancy.is_none(), "{evidence:?}");
1783 assert_eq!(projection.observed_delta, observed_delta, "{evidence:?}");
1784
1785 apply_exact(&mut projection, ObservationEvidenceV1::StructuredProvider);
1786 assert!(projection.context_occupancy.is_some());
1787 apply_exact(&mut projection, evidence);
1788 assert!(projection.context_occupancy.is_none(), "{evidence:?}");
1789 assert_eq!(projection.observed_delta, observed_delta, "{evidence:?}");
1790 }
1791 }
1792
1793 #[test]
1802 fn unobserved_breakdown_propagates_as_none_not_zero() {
1803 let mut projection = UsageProjection::default();
1804 apply_context_window_usage(
1805 &mut projection,
1806 None,
1807 None,
1808 None,
1809 None,
1810 None,
1811 4_200,
1812 200_000,
1813 ObservationEvidenceV1::StructuredProvider,
1814 );
1815 let snapshot = projection
1816 .context_occupancy
1817 .expect("structured provider activates exact-window occupancy");
1818 assert_eq!(snapshot.uncached_input_tokens, None);
1819 assert_eq!(snapshot.cache_read_tokens, None);
1820 assert_eq!(snapshot.cache_write_tokens, None);
1821 assert_eq!(snapshot.output_tokens, None);
1822 assert_eq!(snapshot.unattributed_tokens, None);
1823 assert_eq!(snapshot.reasoning_tokens, None);
1824 assert_eq!(snapshot.used_tokens, 4_200);
1825 assert_eq!(snapshot.context_window, Some(200_000));
1826 assert_eq!(snapshot.provenance, ContextOccupancyProvenance::ExactCurrentWindow);
1827 }
1828}
1829
1830#[derive(Clone, Debug, Eq, Error, PartialEq)]
1831pub enum ObservationEngineError {
1832 #[error(transparent)]
1833 Api(#[from] ObservationApiError),
1834 #[error("node {node_id} cursor {cursor:?} was reused with different canonical ingress")]
1835 CursorCollision { node_id: NodeId, cursor: NodeCursor },
1836 #[error("node {node_id} cursor {cursor:?} is at or below the durable retention floor")]
1837 BelowRetentionFloor { node_id: NodeId, cursor: NodeCursor },
1838 #[error("node {node_id} incarnation {incarnation_id} was already replaced")]
1839 ReplacedIncarnation {
1840 node_id: NodeId,
1841 incarnation_id: NodeIncarnationId,
1842 },
1843 #[error("active {collection} capacity {max} is exhausted")]
1844 CapacityExhausted {
1845 collection: &'static str,
1846 max: usize,
1847 },
1848 #[error("invalid observation engine checkpoint: {detail}")]
1849 InvalidCheckpoint { detail: &'static str },
1850}
1851
1852pub fn gap(first_sequence: u64, last_sequence: u64) -> ObservationGap {
1853 ObservationGap {
1854 first_sequence,
1855 last_sequence,
1856 }
1857}