feat(server,cli): event-sourced health checks — disk watermark probe (observability §4)

Health states are events. A HealthMonitor polls registered HealthProbes on an
interval and appends HealthDegraded/HealthRestored only on a status transition
(hysteresis), so health is observable, replayable, and renderable like every
other fact in the log. The probe reads nondeterministic environment state and
records it at observe-time (Invariant #9); HealthProjection rebuilds current
health by replay, never re-probing (Invariant #8). Monitor is live-only, started
in ServerModule.start alongside narration; seeded from the projection so a
restart does not re-emit a degraded the log already records.

First concrete probe: DiskWatermarkProbe — event-log + CAS size and growth rate,
warning before the 2am surprise. Read surface mirrors `correx stats`:
GET /health/checks replays the system session and `correx health` renders it.
Health events ride the reserved __system__ session, filtered from operator
session listings.

LLAMA_SERVER (liveness + tokens/sec trend) and EVENT_STORE (append/fsync latency)
probes plug into the same HealthProbe framework as follow-ups.
This commit is contained in:
2026-06-13 22:19:33 +04:00
parent 3467826d4a
commit f8fd2601a8
19 changed files with 949 additions and 3 deletions
@@ -0,0 +1,56 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The organs the health monitor watches (observability-spec §4). Each is small, paranoid, and
* continuous; a state change for any one is recorded as a [HealthDegradedEvent] / [HealthRestoredEvent]
* so health is observable, replayable, and renderable like every other fact in the log.
*/
@Serializable
enum class HealthSubject {
/** Local inference process: liveness + tokens/sec degradation (KV-cache pressure, thermal throttle). */
LLAMA_SERVER,
/** The append-only event log: append latency + fsync health. The organ that must never lie. */
EVENT_STORE,
/** CAS + log size and growth rate. Append-only means monotonic growth; warn before the 2am surprise. */
DISK,
}
/**
* A monitored [subject] crossed from healthy into degraded. Emitted only on the edge (the monitor
* holds the last status per subject), so the log records transitions, not every poll. The
* environment observation that triggered it is captured here at observe-time (Hard Invariant #9):
* replay reads this recorded fact and never re-probes the filesystem/process/store.
*
* [metric] names the dimension that crossed (e.g. `total_bytes`, `growth_bytes_per_min`);
* [observedValue] is its value at the edge; [threshold] is the watermark it crossed.
*/
@Serializable
@SerialName("HealthDegraded")
data class HealthDegradedEvent(
val sessionId: SessionId,
val subject: HealthSubject,
val metric: String,
val observedValue: Long,
val threshold: Long,
val detail: String,
) : EventPayload
/**
* A monitored [subject] recovered from degraded back to healthy. Edge-triggered counterpart to
* [HealthDegradedEvent]; [observedValue] is the [metric] value that brought it back under the line.
*/
@Serializable
@SerialName("HealthRestored")
data class HealthRestoredEvent(
val sessionId: SessionId,
val subject: HealthSubject,
val metric: String,
val observedValue: Long,
val detail: String,
) : EventPayload
@@ -19,6 +19,8 @@ import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.HealthDegradedEvent
import com.correx.core.events.events.HealthRestoredEvent
import com.correx.core.events.events.JournalCompactedEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent
@@ -113,6 +115,8 @@ val eventModule = SerializersModule {
subclass(ExecutionPlanLockedEvent::class)
subclass(ExecutionPlanRejectedEvent::class)
subclass(JournalCompactedEvent::class)
subclass(HealthDegradedEvent::class)
subclass(HealthRestoredEvent::class)
}
}
@@ -0,0 +1,62 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.HealthDegradedEvent
import com.correx.core.events.events.HealthRestoredEvent
import com.correx.core.events.events.HealthSubject
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HealthEventsSerializationTest {
@Test
fun `HealthDegradedEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = HealthDegradedEvent(
sessionId = SessionId("__system__"),
subject = HealthSubject.DISK,
metric = "total_bytes",
observedValue = 6_000_000_000,
threshold = 5_000_000_000,
detail = "event log + CAS at 5.6 GiB (warn ≥ 4.7 GiB)",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"HealthDegraded\""), "SerialName must be present: $encoded")
assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), encoded))
}
@Test
fun `HealthRestoredEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = HealthRestoredEvent(
sessionId = SessionId("__system__"),
subject = HealthSubject.DISK,
metric = "total_bytes",
observedValue = 1_000_000_000,
detail = "event log + CAS at 953.7 MiB",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"HealthRestored\""), "SerialName must be present: $encoded")
assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), encoded))
}
@Test
fun `every HealthSubject round-trips`() {
for (subject in HealthSubject.entries) {
val sample: EventPayload = HealthRestoredEvent(
sessionId = SessionId("__system__"),
subject = subject,
metric = "m",
observedValue = 0,
detail = "ok",
)
assertEquals(
sample,
eventJson.decodeFromString(
EventPayload.serializer(),
eventJson.encodeToString(EventPayload.serializer(), sample),
),
)
}
}
}