Skip to main content

deepstrike_core/runtime/kernel/
observation.rs

1//! Scheduler observations projected by the canonical operation driver.
2//!
3//! These are engine facts, not a second host input protocol.
4
5use serde::{Deserialize, Serialize};
6
7use crate::context::pressure::PressureAction;
8use crate::runtime::session::RollbackReason;
9
10use super::wire::command::CancellationReason;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct WorkflowSpawnFailure {
14    pub agent_id: String,
15    pub error: String,
16}
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "kind", rename_all = "snake_case")]
19pub enum KernelObservation {
20    /// Synchronous in-kernel compaction fact. Archived content is carried only by
21    /// `ArchivePageOut`; it never rides an observation into host I/O.
22    Compressed {
23        #[serde(default)]
24        turn: u32,
25        action: KernelPressureAction,
26        rho_after: f64,
27        summary: Option<String>,
28        archived_count: u32,
29        /// W1-1 cache-awareness: the message index at which this compression invalidated the
30        /// prompt cache prefix (if any). `None` = prefix-safe. SDK/telemetry can use this to
31        /// quantify "tokens saved vs cache rebuild cost". Additive ABI field with default.
32        #[serde(default, skip_serializing_if = "Option::is_none")]
33        invalidates_prefix_at: Option<usize>,
34    },
35    Renewed {
36        sprint: u32,
37    },
38    /// Rendering proved that fixed context or the protected transaction tail cannot fit inside the
39    /// declared input budget. No provider effect is emitted for this turn.
40    ContextBudgetExceeded {
41        turn: u32,
42        overflow_kind: crate::context::renderer::ContextBudgetOverflowKind,
43        required_tokens: u32,
44        max_tokens: u32,
45    },
46    /// K1: a boundary sweep of the knowledge partition applied deferred upserts and/or dropped
47    /// marked entries. `removed_keys` lists keyed removals (unkeyed drops count only in
48    /// `tokens_freed`); an upsert-only sweep has empty `removed_keys`.
49    KnowledgeSwept {
50        turn: u32,
51        #[serde(default, skip_serializing_if = "Vec::is_empty")]
52        removed_keys: Vec<String>,
53        tokens_freed: u32,
54    },
55    /// K2: the knowledge partition exceeds its configured budget share. Fired at most once per
56    /// cache generation; the over-budget unpinned entries are already marked for the next
57    /// boundary sweep. Pinned/skill weight that cannot be evicted keeps the warning standing.
58    KnowledgeBudgetExceeded {
59        turn: u32,
60        used: u32,
61        budget: u32,
62    },
63    Rollbacked {
64        turn: u32,
65        checkpoint_history_len: u32,
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        reason: Option<RollbackReason>,
68    },
69    /// A control-plane request was rejected before its effect started. Unlike `Rollbacked`, this is
70    /// a committed result: there is no transaction to undo, and hosts can route the reason back to
71    /// the caller without mistaking a missing success observation for an internal failure.
72    ControlRequestRejected {
73        turn: u32,
74        operation: String,
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        subject: Option<String>,
77        reason: String,
78    },
79    CapabilityChanged {
80        turn: u32,
81        #[serde(default, skip_serializing_if = "Vec::is_empty")]
82        added: Vec<String>,
83        #[serde(default, skip_serializing_if = "Vec::is_empty")]
84        removed: Vec<String>,
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        change_kind: Option<String>,
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        capability_id: Option<String>,
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        version: Option<String>,
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        mounted_by: Option<String>,
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        mount_reason: Option<String>,
95    },
96    MilestoneAdvanced {
97        turn: u32,
98        phase_id: String,
99        capabilities_unlocked: Vec<String>,
100    },
101    MilestoneBlocked {
102        turn: u32,
103        phase_id: String,
104        reason: String,
105    },
106    /// Checkpoint taken at the start of a turn transaction (before LLM call).
107    CheckpointTaken {
108        turn: u32,
109        history_len: u32,
110    },
111    /// O6: the repeat fuse tripped — the same turn signature (non-meta tool name AND args) was
112    /// re-issued `count`x consecutively. `action` = `"deny"` (turn rolled back, directive note fed
113    /// back) or `"terminate"` (run ends `no_progress` after one final report turn). Additive ABI.
114    RepeatFuseTripped {
115        turn: u32,
116        signature: String,
117        count: u32,
118        action: String,
119    },
120    /// O4: the turn-end criteria gate fired — the model tried to finish while acceptance criteria
121    /// stand; the kernel injected one self-check turn before accepting `Completed`. Additive ABI.
122    CriteriaGateFired {
123        turn: u32,
124        criteria: Vec<String>,
125    },
126    /// Session-entropy sample at a completed turn boundary (the heartbeat watch source).
127    /// One per completed turn, unconditional — like `CheckpointTaken`. The component
128    /// vector is the contract; `score` is the canonical default fold.
129    /// See `scheduler::entropy`. Additive ABI.
130    EntropySample {
131        turn: u32,
132        score: f64,
133        rho: f64,
134        repeat_pressure: f64,
135        failure_rate: f64,
136        rollbacks_in_window: u32,
137        window_turns: u32,
138    },
139    /// The opt-in entropy watch tripped: `score` crossed `threshold` while armed and
140    /// cooled down (`EntropyWatchConfig`). Correlate components via the same-turn
141    /// `EntropySample`. Additive ABI.
142    EntropyAlert {
143        turn: u32,
144        score: f64,
145        threshold: f64,
146    },
147    /// Kernel process table changed for a spawned sub-agent.
148    ///
149    /// § Task 11 · lineage is the logical parent **task** id. The kernel does not know, and never
150    /// echoes, which host session a child belongs to: mapping child task → child session is the
151    /// host's (§5.2), and a host projection is free to keep restating its own session on the
152    /// SessionLog event it derives from this observation.
153    AgentProcessChanged {
154        turn: u32,
155        agent_id: String,
156        parent_task_id: String,
157        role: String,
158        isolation: String,
159        context_inheritance: String,
160        state: String,
161        #[serde(default, skip_serializing_if = "Vec::is_empty")]
162        permitted_capability_ids: Vec<String>,
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        result_termination: Option<String>,
165    },
166    /// One child attempt failed and the parent's local supervision policy adjudicated it.
167    ChildSupervised {
168        turn: u32,
169        task_id: String,
170        attempt: u32,
171        strategy: String,
172        reason: String,
173        terminal: bool,
174        relaunched: bool,
175    },
176    /// Deterministic local merge trace shared by DAG nodes and woken/nested task processes.
177    LocalRunnableTrace {
178        turn: u32,
179        runnable: Vec<crate::scheduler::runnable::LocalRunnable>,
180    },
181    /// W0-ABI: a workflow batch was spawned — each node's spawn descriptor (agent id + goal +
182    /// role/isolation/inheritance) so the SDK can run the kernel-generated nodes.
183    WorkflowBatchSpawned {
184        turn: u32,
185        nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
186        /// G4 budget-as-signal: the workflow's remaining headroom under the active quota at spawn
187        /// time, so a coordinator node can scale its next submission. Additive: omitted when no
188        /// resource quota is installed (nothing to report).
189        #[serde(default, skip_serializing_if = "Option::is_none")]
190        budget: Option<crate::orchestration::workflow::WorkflowBudget>,
191    },
192    /// The host could not resolve a workflow spawn effect. No node is recorded
193    /// as started; the same logical batch remains pending for retry.
194    WorkflowSpawnFailed {
195        turn: u32,
196        error: String,
197    },
198    /// W0-ABI: a workflow finished (all nodes terminal, or stalled by a gated dependency).
199    WorkflowCompleted {
200        turn: u32,
201        node_outcomes: Vec<crate::orchestration::workflow::run::WorkflowNodeOutcome>,
202    },
203    /// #2-B: a high-urgency `InterruptNow` signal preempted in-flight work. The kernel has already
204    /// marked these agents `Done(UserAbort)` and reclaimed the root to reason about the interrupt; the
205    /// SDK must ABORT the listed in-flight child runs and discard their results (do NOT feed their
206    /// `SubAgentCompleted`). Additive variant (`agent_preempted`) — byte-identical for SDKs that never
207    /// receive it.
208    AgentPreempted {
209        turn: u32,
210        #[serde(default, skip_serializing_if = "Vec::is_empty")]
211        agent_ids: Vec<String>,
212        reason: String,
213    },
214    AgentPreemptFailed {
215        turn: u32,
216        agent_ids: Vec<String>,
217        reason: String,
218        error: String,
219    },
220    /// ③ loop-agent pacing: the kernel adjudicated a `pace` proposal for this round.
221    RoundPaced {
222        turn: u32,
223        round: u32,
224        decision: crate::types::result::PaceDecision,
225    },
226    /// R3-1: a runtime node submission was appended to the in-flight DAG at `base`
227    /// (the graph length before the append). The SDK records `base` on the
228    /// `workflow_nodes_submitted` session event so resume can re-apply the batch at
229    /// the exact original indices (gap-filling any interleaved runtime children).
230    WorkflowNodesSubmitted {
231        turn: u32,
232        base: u32,
233        count: u32,
234        /// W-N3: the submitting node's agent id (`None` = host/bootstrap). Persisted so resume can
235        /// DROP batches whose submitter re-runs (it will re-submit) instead of duplicating them.
236        #[serde(default, skip_serializing_if = "Option::is_none")]
237        submitter: Option<String>,
238    },
239    /// A runtime node batch was rejected before any graph mutation.
240    NodesRejected {
241        turn: u32,
242        node_index: u32,
243        reason: String,
244    },
245    /// A tool call needs user approval (governance `AskUser`). Not blocked by the
246    /// kernel — the SDK must obtain approval before executing the named call.
247    ToolGated {
248        turn: u32,
249        call_id: String,
250        tool: String,
251        reason: String,
252    },
253    /// A leased inbound signal delivery was routed by the in-kernel attention policy.
254    SignalDeliveryDisposed {
255        turn: u32,
256        operation_id: String,
257        delivery_id: String,
258        attempt: u32,
259        signal_id: String,
260        disposition: String,
261        queue_depth: u32,
262    },
263    SignalDisplaced {
264        turn: u32,
265        admitted_signal_id: String,
266        displaced_signal_id: String,
267        queue_depth: u32,
268    },
269    SignalExpired {
270        turn: u32,
271        signal_id: String,
272        queue_depth: u32,
273    },
274    SignalsPending {
275        turn: u32,
276        depth: u32,
277    },
278    /// A budget axis (turns / tokens / wall-time) was exhausted.
279    BudgetExceeded {
280        turn: u32,
281        budget: String,
282        operation_id: String,
283        #[serde(default, skip_serializing_if = "Option::is_none")]
284        reservation_id: Option<String>,
285    },
286    /// Terminal local usage for one reservation. Emitted exactly once per operation.
287    BudgetUsageReported {
288        operation_id: String,
289        reservation_id: String,
290        tokens: u64,
291        subagents: u32,
292        rounds: u32,
293    },
294    /// §13.2 / DEC-6 · a revision-guarded live policy patch was applied.
295    ///
296    /// A fact, not a command: the new policy is already installed when this is emitted, and the
297    /// host is asked for nothing. `revision` is the counter *after* the patch — the value the next
298    /// writer must present as its `expected_revision`, which is what makes a refused patch
299    /// rebaseable instead of a silent overwrite.
300    LivePolicyChanged {
301        turn: u32,
302        /// Which of the four §13.2 policies changed (`signal` / `governance` / `resource_quota` /
303        /// `recovery`).
304        policy: String,
305        revision: u64,
306    },
307    /// A host cancellation was committed. Emitted exactly once by the accepted cancellation step.
308    OperationCancelled {
309        turn: u32,
310        operation_id: String,
311        reason: CancellationReason,
312        #[serde(default, skip_serializing_if = "Vec::is_empty")]
313        pending_call_ids: Vec<String>,
314    },
315    /// Loop entered `Suspended` state (awaiting human approval or sub-agent).
316    Suspended {
317        turn: u32,
318        reason: String,
319        #[serde(default, skip_serializing_if = "Vec::is_empty")]
320        pending_calls: Vec<String>,
321    },
322    /// Loop resumed from `Suspended` state.
323    Resumed {
324        turn: u32,
325        #[serde(default, skip_serializing_if = "Vec::is_empty")]
326        approved: Vec<String>,
327        #[serde(default, skip_serializing_if = "Vec::is_empty")]
328        denied: Vec<String>,
329    },
330    ApprovalResolutionFailed {
331        turn: u32,
332        error: String,
333    },
334    /// Memory entry written successfully (Phase 7).
335    MemoryWritten {
336        turn: u32,
337        record_id: String,
338        scope: crate::mm::memory::MemoryScope,
339        memory_kind: crate::mm::memory::MemoryKind,
340        name: String,
341        size_bytes: u32,
342    },
343    /// Memory validation failed (Phase 7).
344    MemoryValidationFailed {
345        turn: u32,
346        record_id: String,
347        error: String,
348    },
349    MemoryWriteFailed {
350        turn: u32,
351        record_id: String,
352        error: String,
353    },
354    /// Memory query request (Phase 7).
355    MemoryQueried {
356        turn: u32,
357        scope: crate::mm::memory::MemoryScope,
358        query: String,
359        requested_k: usize,
360        requires_async_response: bool,
361    },
362    MemoryQueryFailed {
363        turn: u32,
364        scope: crate::mm::memory::MemoryScope,
365        query: String,
366        error: String,
367    },
368    /// M3: recall lifecycle was journaled for one or more recalled records. Derived from the routed
369    /// hits (each carries its current count); the host mirrors the incremented counts into its
370    /// durable store so recall history survives across sessions.
371    MemoryRecalled {
372        turn: u32,
373        scope: crate::mm::memory::MemoryScope,
374        recalls: Vec<crate::mm::memory::MemoryRecallLifecycle>,
375    },
376    /// M4: a recalled record crossed the promotion threshold. Advisory only — the host/model decides
377    /// whether to pin it or promote its content into knowledge.
378    PromotionSuggested {
379        turn: u32,
380        record_id: String,
381        recall_count: u64,
382    },
383    PageOutArchived {
384        turn: u32,
385        action: KernelPressureAction,
386        summary: Option<String>,
387        tier: String,
388        message_count: u32,
389        #[serde(default, skip_serializing_if = "Option::is_none")]
390        archive_ref: Option<String>,
391    },
392    PageOutArchiveFailed {
393        turn: u32,
394        action: KernelPressureAction,
395        tier: String,
396        message_count: u32,
397        error: String,
398    },
399    /// §7.10 / §25.9 · a P3 handle's payload residency moved.
400    ///
401    /// The handle table is the kernel's only fact about where a body lives, so every transfer is a
402    /// committed fact — including the first one, where an external result was never resident at
403    /// all (`from` is then absent, because the kernel minted the handle for this very transfer).
404    /// `payload_ref` is the opaque host locator while the body is outside core, and absent once it
405    /// has come home.
406    PayloadResidencyChanged {
407        turn: u32,
408        handle_id: String,
409        #[serde(default, skip_serializing_if = "Option::is_none")]
410        from: Option<String>,
411        to: String,
412        #[serde(default, skip_serializing_if = "Option::is_none")]
413        payload_ref: Option<String>,
414        original_size: u64,
415    },
416    /// §7.10 · a page-in the host could not satisfy. DEC-5: one decision, taken once — the read is
417    /// abandoned and the loop continues, because a body the host cannot produce is a degraded read,
418    /// not an unsound operation.
419    PayloadLoadFailed {
420        turn: u32,
421        handle_id: String,
422        error: String,
423    },
424    /// Host-side audit fact — the kernel **never emits this observation itself** (an observation
425    /// the kernel produced would ride the planned step into `step_digest`, breaking replay of every
426    /// existing journal). Each host projection adds it to its event log when a committed step
427    /// publishes effects, so a journal's effect manifest — the very fact the record only stores a
428    /// digest of — is recoverable post-hoc without replaying. Contract tests bind only
429    /// `effect_id` + `kind`; the payload is stable and additive.
430    StepPublishedEffects {
431        effects: Vec<PublishedEffectRef>,
432    },
433}
434
435/// One entry of [`KernelObservation::StepPublishedEffects`]: the identity and kind of an effect a
436/// committed step published. The kind is the wire tag (`snake_case`), not the Rust variant name.
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438#[serde(deny_unknown_fields)]
439pub struct PublishedEffectRef {
440    pub effect_id: String,
441    pub kind: String,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum KernelPressureAction {
447    None,
448    SnipCompact,
449    MicroCompact,
450    ContextCollapse,
451    AutoCompact,
452}
453
454impl From<PressureAction> for KernelPressureAction {
455    fn from(action: PressureAction) -> Self {
456        match action {
457            PressureAction::None => Self::None,
458            PressureAction::SnipCompact => Self::SnipCompact,
459            PressureAction::MicroCompact => Self::MicroCompact,
460            PressureAction::ContextCollapse => Self::ContextCollapse,
461            PressureAction::AutoCompact => Self::AutoCompact,
462        }
463    }
464}