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}