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}