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 a versioned default fold (`score_version`).
129    /// See `scheduler::entropy`. Additive ABI.
130    EntropySample {
131        turn: u32,
132        score: f64,
133        score_version: u32,
134        rho: f64,
135        repeat_pressure: f64,
136        failure_rate: f64,
137        rollbacks_in_window: u32,
138        window_turns: u32,
139    },
140    /// The opt-in entropy watch tripped: `score` crossed `threshold` while armed and
141    /// cooled down (`EntropyWatchConfig`). Correlate components via the same-turn
142    /// `EntropySample`. Additive ABI.
143    EntropyAlert {
144        turn: u32,
145        score: f64,
146        threshold: f64,
147    },
148    /// Kernel process table changed for a spawned sub-agent.
149    ///
150    /// § Task 11 · lineage is the logical parent **task** id. The kernel does not know, and never
151    /// echoes, which host session a child belongs to: mapping child task → child session is the
152    /// host's (§5.2), and a host projection is free to keep restating its own session on the
153    /// SessionLog event it derives from this observation.
154    AgentProcessChanged {
155        turn: u32,
156        agent_id: String,
157        parent_task_id: String,
158        role: String,
159        isolation: String,
160        context_inheritance: String,
161        state: String,
162        #[serde(default, skip_serializing_if = "Vec::is_empty")]
163        permitted_capability_ids: Vec<String>,
164        #[serde(default, skip_serializing_if = "Option::is_none")]
165        result_termination: Option<String>,
166    },
167    /// W0-ABI: a workflow batch was spawned — each node's spawn descriptor (agent id + goal +
168    /// role/isolation/inheritance) so the SDK can run the kernel-generated nodes.
169    WorkflowBatchSpawned {
170        turn: u32,
171        nodes: Vec<crate::orchestration::workflow::WorkflowSpawnInfo>,
172        /// G4 budget-as-signal: the workflow's remaining headroom under the active quota at spawn
173        /// time, so a coordinator node can scale its next submission. Additive: omitted when no
174        /// resource quota is installed (nothing to report).
175        #[serde(default, skip_serializing_if = "Option::is_none")]
176        budget: Option<crate::orchestration::workflow::WorkflowBudget>,
177    },
178    /// The host could not resolve a workflow spawn effect. No node is recorded
179    /// as started; the same logical batch remains pending for retry.
180    WorkflowSpawnFailed {
181        turn: u32,
182        error: String,
183    },
184    /// W0-ABI: a workflow finished (all nodes terminal, or stalled by a gated dependency).
185    WorkflowCompleted {
186        turn: u32,
187        node_outcomes: Vec<crate::orchestration::workflow::run::WorkflowNodeOutcome>,
188    },
189    /// #2-B: a high-urgency `InterruptNow` signal preempted in-flight work. The kernel has already
190    /// marked these agents `Done(UserAbort)` and reclaimed the root to reason about the interrupt; the
191    /// SDK must ABORT the listed in-flight child runs and discard their results (do NOT feed their
192    /// `SubAgentCompleted`). Additive variant (`agent_preempted`) — byte-identical for SDKs that never
193    /// receive it.
194    AgentPreempted {
195        turn: u32,
196        #[serde(default, skip_serializing_if = "Vec::is_empty")]
197        agent_ids: Vec<String>,
198        reason: String,
199    },
200    AgentPreemptFailed {
201        turn: u32,
202        agent_ids: Vec<String>,
203        reason: String,
204        error: String,
205    },
206    /// ③ loop-agent pacing: the kernel adjudicated a `pace` proposal for this round.
207    RoundPaced {
208        turn: u32,
209        round: u32,
210        decision: crate::types::result::PaceDecision,
211    },
212    /// R3-1: a runtime node submission was appended to the in-flight DAG at `base`
213    /// (the graph length before the append). The SDK records `base` on the
214    /// `workflow_nodes_submitted` session event so resume can re-apply the batch at
215    /// the exact original indices (gap-filling any interleaved runtime children).
216    WorkflowNodesSubmitted {
217        turn: u32,
218        base: u32,
219        count: u32,
220        /// W-N3: the submitting node's agent id (`None` = host/bootstrap). Persisted so resume can
221        /// DROP batches whose submitter re-runs (it will re-submit) instead of duplicating them.
222        #[serde(default, skip_serializing_if = "Option::is_none")]
223        submitter: Option<String>,
224    },
225    /// A runtime node batch was rejected before any graph mutation.
226    NodesRejected {
227        turn: u32,
228        node_index: u32,
229        reason: String,
230    },
231    /// A tool call needs user approval (governance `AskUser`). Not blocked by the
232    /// kernel — the SDK must obtain approval before executing the named call.
233    ToolGated {
234        turn: u32,
235        call_id: String,
236        tool: String,
237        reason: String,
238    },
239    /// A leased inbound signal delivery was routed by the in-kernel attention policy.
240    SignalDeliveryDisposed {
241        turn: u32,
242        operation_id: String,
243        delivery_id: String,
244        attempt: u32,
245        signal_id: String,
246        disposition: String,
247        queue_depth: u32,
248    },
249    SignalDisplaced {
250        turn: u32,
251        admitted_signal_id: String,
252        displaced_signal_id: String,
253        queue_depth: u32,
254    },
255    SignalExpired {
256        turn: u32,
257        signal_id: String,
258        queue_depth: u32,
259    },
260    SignalsPending {
261        turn: u32,
262        depth: u32,
263    },
264    /// A budget axis (turns / tokens / wall-time) was exhausted.
265    BudgetExceeded {
266        turn: u32,
267        budget: String,
268        operation_id: String,
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        reservation_id: Option<String>,
271    },
272    /// Terminal local usage for one reservation. Emitted exactly once per operation.
273    BudgetUsageReported {
274        operation_id: String,
275        reservation_id: String,
276        tokens: u64,
277        subagents: u32,
278        rounds: u32,
279    },
280    /// §13.2 / DEC-6 · a revision-guarded live policy patch was applied.
281    ///
282    /// A fact, not a command: the new policy is already installed when this is emitted, and the
283    /// host is asked for nothing. `revision` is the counter *after* the patch — the value the next
284    /// writer must present as its `expected_revision`, which is what makes a refused patch
285    /// rebaseable instead of a silent overwrite.
286    LivePolicyChanged {
287        turn: u32,
288        /// Which of the four §13.2 policies changed (`signal` / `governance` / `resource_quota` /
289        /// `recovery`).
290        policy: String,
291        revision: u64,
292    },
293    /// A host cancellation was committed. Emitted exactly once by the accepted cancellation step.
294    OperationCancelled {
295        turn: u32,
296        operation_id: String,
297        reason: CancellationReason,
298        #[serde(default, skip_serializing_if = "Vec::is_empty")]
299        pending_call_ids: Vec<String>,
300    },
301    /// Loop entered `Suspended` state (awaiting human approval or sub-agent).
302    Suspended {
303        turn: u32,
304        reason: String,
305        #[serde(default, skip_serializing_if = "Vec::is_empty")]
306        pending_calls: Vec<String>,
307    },
308    /// Loop resumed from `Suspended` state.
309    Resumed {
310        turn: u32,
311        #[serde(default, skip_serializing_if = "Vec::is_empty")]
312        approved: Vec<String>,
313        #[serde(default, skip_serializing_if = "Vec::is_empty")]
314        denied: Vec<String>,
315    },
316    ApprovalResolutionFailed {
317        turn: u32,
318        error: String,
319    },
320    /// Memory entry written successfully (Phase 7).
321    MemoryWritten {
322        turn: u32,
323        record_id: String,
324        scope: crate::mm::memory::MemoryScope,
325        memory_kind: crate::mm::memory::MemoryKind,
326        name: String,
327        size_bytes: u32,
328    },
329    /// Memory validation failed (Phase 7).
330    MemoryValidationFailed {
331        turn: u32,
332        record_id: String,
333        error: String,
334    },
335    MemoryWriteFailed {
336        turn: u32,
337        record_id: String,
338        error: String,
339    },
340    /// Memory query request (Phase 7).
341    MemoryQueried {
342        turn: u32,
343        scope: crate::mm::memory::MemoryScope,
344        query: String,
345        requested_k: usize,
346        requires_async_response: bool,
347    },
348    MemoryQueryFailed {
349        turn: u32,
350        scope: crate::mm::memory::MemoryScope,
351        query: String,
352        error: String,
353    },
354    /// M3: recall lifecycle was journaled for one or more recalled records. Derived from the routed
355    /// hits (each carries its current count); the host mirrors the incremented counts into its
356    /// durable store so recall history survives across sessions.
357    MemoryRecalled {
358        turn: u32,
359        scope: crate::mm::memory::MemoryScope,
360        recalls: Vec<crate::mm::memory::MemoryRecallLifecycle>,
361    },
362    /// M4: a recalled record crossed the promotion threshold. Advisory only — the host/model decides
363    /// whether to pin it or promote its content into knowledge.
364    PromotionSuggested {
365        turn: u32,
366        record_id: String,
367        recall_count: u64,
368    },
369    PageOutArchived {
370        turn: u32,
371        action: KernelPressureAction,
372        summary: Option<String>,
373        tier: String,
374        message_count: u32,
375        #[serde(default, skip_serializing_if = "Option::is_none")]
376        archive_ref: Option<String>,
377    },
378    PageOutArchiveFailed {
379        turn: u32,
380        action: KernelPressureAction,
381        tier: String,
382        message_count: u32,
383        error: String,
384    },
385    /// §7.10 / §25.9 · a P3 handle's payload residency moved.
386    ///
387    /// The handle table is the kernel's only fact about where a body lives, so every transfer is a
388    /// committed fact — including the first one, where an external result was never resident at
389    /// all (`from` is then absent, because the kernel minted the handle for this very transfer).
390    /// `payload_ref` is the opaque host locator while the body is outside core, and absent once it
391    /// has come home.
392    PayloadResidencyChanged {
393        turn: u32,
394        handle_id: String,
395        #[serde(default, skip_serializing_if = "Option::is_none")]
396        from: Option<String>,
397        to: String,
398        #[serde(default, skip_serializing_if = "Option::is_none")]
399        payload_ref: Option<String>,
400        original_size: u64,
401    },
402    /// §7.10 · a page-in the host could not satisfy. DEC-5: one decision, taken once — the read is
403    /// abandoned and the loop continues, because a body the host cannot produce is a degraded read,
404    /// not an unsound operation.
405    PayloadLoadFailed {
406        turn: u32,
407        handle_id: String,
408        error: String,
409    },
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "snake_case")]
414pub enum KernelPressureAction {
415    None,
416    SnipCompact,
417    MicroCompact,
418    ContextCollapse,
419    AutoCompact,
420}
421
422impl From<PressureAction> for KernelPressureAction {
423    fn from(action: PressureAction) -> Self {
424        match action {
425            PressureAction::None => Self::None,
426            PressureAction::SnipCompact => Self::SnipCompact,
427            PressureAction::MicroCompact => Self::MicroCompact,
428            PressureAction::ContextCollapse => Self::ContextCollapse,
429            PressureAction::AutoCompact => Self::AutoCompact,
430        }
431    }
432}