A Monitor That Outlives Startup
The failure that arrives at 03:02
Chapter 33 put a gate in front of her startup: every component gets poked with a sentinel input, and a broken one blocks the boot instead of ruining a conversation. Chapter 49 gave her a live view, counters and gauges and a window of recent latencies, printed while you watch. Both are honest. Both answer only when someone is there to ask.
The failure that actually arrives does not wait for someone to be there. Her disk sat at
71 percent for months, and every conversation added a little: a row in
memory.db, an embedding blob per remembered fact, a scratch WAV the cleanup
script misses one time in twenty. Then a config edit on Tuesday leaves a debug log open
and the drive starts gaining half a point an hour, which is too slow to see on a screen
and fast enough to matter. Nothing crashes for two days. Every health probe she owns
still passes, because none of them has run since the last reboot. At 03:02 on Thursday a
commit fails, and she stops remembering anything anyone says.
Chapter 84 already handed her to systemd with Restart=on-failure and a
watchdog, and it is tempting to think that covers this. It does not. A supervisor answers
one question: is the process alive. A full disk does not kill the process, it kills one
write, and the restarted process wakes up on the same full disk and fails the same way
four seconds later. Restarting is a reflex. What is missing is a diagnosis, and a
diagnosis needs something a reflex never has: a record of how the machine has been
trending since yesterday.
So the rule for this chapter has three clauses in it: a check reports failure instead of raising it, every reading it produces is kept so a trend exists, and a verdict only becomes a message once it has held long enough to mean something. Failure as data is what keeps one dead subsystem from blinding you to the other two. History is what turns "88 percent used" into "full in a day". The third clause is the one that decides whether anybody is still reading her messages in a month.
Everything the monitor learns goes to exactly one of three places. Most of it goes nowhere: on a good day almost every one of the 1,440 polls finds nothing new and should produce no output at all. Some of it goes to the journal, where it costs nothing and waits for you to come looking. A very small amount reaches a person who is asleep. Deciding which pile a reading belongs in is the actual design work here; the checks themselves are the easy part.
Checks that report instead of crash
# labs/health_monitor.py
import shutil
from dataclasses import dataclass
DISK_WARN_PCT = 85.0
GIB = 1024 ** 3
@dataclass(frozen=True)
class HealthStatus:
name: str
ok: bool
detail: str
value: float | None = None
def check_disk(path: str = "/") -> HealthStatus:
usage = shutil.disk_usage(path)
pct = usage.used / usage.total * 100
return HealthStatus("disk", pct < DISK_WARN_PCT,
f"{pct:.1f}% used, {usage.free / GIB:.1f} GiB free",
round(pct, 1))
if __name__ == "__main__":
print(check_disk())
$ uv run python -m labs.health_monitor # your drive will read differently
HealthStatus(name='disk', ok=True, detail='71.2% used, 67.1 GiB free', value=71.2)
Chapter 33's CheckResult carried a name, a verdict and a message, and for a
probe that runs once that was enough. A standing monitor needs a fourth field, because
detail is a sentence for a human and you cannot do arithmetic on a
sentence. value is the same reading as a number, and every trend in this
chapter is computed from it. It defaults to None for a reason the next
stage makes concrete.
frozen=True costs one word and buys something specific. A status is a
record of one instant, and it is about to be appended to a history that other code will
read minutes later; freezing it means nothing downstream can quietly edit the past.
Note also that the threshold lives in a named constant and the verdict is computed from
it. That comparison is the seam every policy in this chapter reads.
import json
import urllib.request
MEM_WARN_PCT = 85.0
OLLAMA_TAGS_URL = "http://127.0.0.1:11434/api/tags"
PROBE_TIMEOUT = 3.0
def check_memory(path: str = "/proc/meminfo") -> HealthStatus:
try:
info: dict[str, int] = {}
with open(path) as f:
for line in f:
parts = line.split()
if len(parts) >= 2:
info[parts[0].rstrip(":")] = int(parts[1])
total, available = info["MemTotal"], info["MemAvailable"]
except (OSError, KeyError, ValueError) as e:
return HealthStatus("memory", False, f"unreadable: {e.__class__.__name__}: {e}")
pct = (total - available) / total * 100
return HealthStatus("memory", pct < MEM_WARN_PCT,
f"{pct:.1f}% used, {available / (1024 * 1024):.1f} GiB available",
round(pct, 1))
def check_ollama(url: str = OLLAMA_TAGS_URL) -> HealthStatus:
try:
with urllib.request.urlopen(url, timeout=PROBE_TIMEOUT) as reply:
models = json.loads(reply.read()).get("models", [])
except Exception as e:
return HealthStatus("ollama", False, f"{e.__class__.__name__}: {e}")
return HealthStatus("ollama", True, f"{len(models)} model(s) available",
float(len(models)))
$ uv run python -m labs.health_monitor # your box, your model list
HealthStatus(name='memory', ok=True, detail='41.8% used, 4.3 GiB available', value=41.8)
HealthStatus(name='ollama', ok=True, detail='6 model(s) available', value=6.0)
$ sudo systemctl stop ollama; uv run python -m labs.health_monitor
HealthStatus(name='ollama', ok=False, detail='URLError: <urlopen error [Errno 111] Connection refused>', value=None)
Read MemAvailable, never MemFree. Linux spends every spare
page on cache and gives it back the instant a program wants it, so MemFree
on a healthy machine that has been up for a week is a number near zero that means
nothing at all. MemAvailable is the kernel's own estimate of what a new
process could actually get, which is the only figure a memory alarm should ever compare
against.
The two except clauses differ on purpose. Reading a file has a small,
nameable set of ways to go wrong, so check_memory lists them and lets a
genuine bug in its own parsing crash loudly during development. An HTTP call to another
process has an open-ended set: refused connections, timeouts, resets, redirects, HTML
error pages that fail to parse as JSON. Enumerating those is a losing game, and for a
probe, any failure to determine health is itself the health signal. Note what the failed
status carries: a detail string and value=None. A failed check has no
measurement. Writing 0.0 there would put a fake reading into the history
and drag every average and every trend toward a number nothing ever measured.
import time
CHECKS = (check_ollama, check_disk, check_memory)
def run_checks() -> list[HealthStatus]:
return [check() for check in CHECKS]
def render(checks: list[HealthStatus], at: float) -> str:
lines = [f"[{time.strftime('%H:%M:%S', time.localtime(at))}] health"]
for c in checks:
lines.append(f" {'ok ' if c.ok else 'FAIL'} {c.name:<7} {c.detail}")
failed = [c.name for c in checks if not c.ok]
lines.append(f" degraded: {', '.join(failed)}" if failed else " all nominal")
return "\n".join(lines)
if __name__ == "__main__":
print(render(run_checks(), time.time()))
$ uv run python -m labs.health_monitor # with ollama stopped
[21:04:11] health
FAIL ollama URLError: <urlopen error [Errno 111] Connection refused>
ok disk 71.2% used, 67.1 GiB free
ok memory 41.8% used, 4.3 GiB available
degraded: ollama
That list comprehension is the whole argument for returning failure as a value. The
model server is down, and the disk and memory readings are still on the screen. Let
check_ollama raise instead and the comprehension unwinds on its first
element, the other two checks never execute, and the one moment you most need a full
picture of the machine is the moment the tool gives you a traceback about one part of
it.
render touches three fields and knows nothing about sockets,
/proc, or partitions. Adding a fourth check is one function and one entry
in CHECKS; the renderer, the history and the alerting below all keep
working untouched. That is the payoff for making every check return the same type.
A number with a past
from collections import deque
HISTORY_POLLS = 720 # 12 hours at one poll a minute
MIN_TREND_HOURS = 2.0 # below this a slope is noise
class History:
def __init__(self, keep: int = HISTORY_POLLS) -> None:
self._keep = keep
self._series: dict[str, deque[tuple[float, float]]] = {}
def record(self, at: float, status: HealthStatus) -> None:
if status.value is None:
return
self._series.setdefault(status.name, deque(maxlen=self._keep)).append((at, status.value))
def latest(self, name: str) -> float | None:
series = self._series.get(name)
return series[-1][1] if series else None
def rate_per_hour(self, name: str) -> float | None:
series = self._series.get(name)
if series is None or len(series) < 2:
return None
(t0, v0), (t1, v1) = series[0], series[-1]
hours = (t1 - t0) / 3600
if hours < MIN_TREND_HOURS:
return None
return (v1 - v0) / hours
def hours_until(self, name: str, limit: float) -> float | None:
rate = self.rate_per_hour(name)
if rate is None or rate <= 0:
return None
return (limit - self._series[name][-1][1]) / rate
if __name__ == "__main__":
h, at = History(), 1_756_000_000.0
for i in range(12): # twelve hourly readings, half a point apart
pct = 88.0 + 0.5 * i
h.record(at + i * 3600,
HealthStatus("disk", pct < DISK_WARN_PCT, f"{pct:.1f}% used", pct))
print(f"latest {h.latest('disk'):.1f} %")
print(f"rate {h.rate_per_hour('disk'):+.2f} %/hour")
print(f"hours to 100 {h.hours_until('disk', 100.0):.1f}")
$ uv run python -m labs.health_monitor
latest 93.5 %
rate +0.50 %/hour
hours to 100 13.0
Every number there is checkable by hand. Twelve readings from 88.0 rising by 0.5 each end at 93.5, spread across eleven hours, so the slope is 5.5 divided by 11, exactly half a point an hour. The remaining 6.5 points at that rate is thirteen hours. That last line is the sentence chapter 33's probe could never produce and a restart can never produce: not "the disk is fine", but "the disk stops working before dinner".
One deque per check name, each capped at 720 entries, is chapter 49's ring buffer doing
the same job for a different measurement. record drops a status whose
value is None, so an hour of connection failures leaves a gap
in the series and no false readings in it. MIN_TREND_HOURS is the guard
that keeps the monitor quiet for its first two hours after a restart: a slope drawn
through four minutes of readings rounded to one decimal place is mostly rounding, and
it will happily predict a full disk by teatime. Two endpoints are the crudest possible
slope, and one spike at either end tilts the whole line. Averaging the first five and
the last five readings costs three more lines and fixes that; the demo above answers
identically either way, so make the change when your readings are noisy rather than
because it looks more serious.
@dataclass(frozen=True)
class Policy:
fail_polls: int # consecutive failures before this counts as a fault
clear_polls: int # consecutive passes before the fault is over
level: str # severity when it fires
limit: float | None = None # the value at which the subsystem stops working
urgent_hours: float | None = None # inside this many hours of the limit, wake someone
POLICIES = {
"ollama": Policy(fail_polls=2, clear_polls=2, level="CRITICAL"),
"disk": Policy(fail_polls=5, clear_polls=5, level="WARNING",
limit=100.0, urgent_hours=24.0),
"memory": Policy(fail_polls=5, clear_polls=5, level="WARNING",
limit=100.0, urgent_hours=2.0),
}
DEFAULT_POLICY = Policy(fail_polls=3, clear_polls=3, level="WARNING")
class Debouncer:
"""Turns a stream of per-poll verdicts into rare state changes."""
def __init__(self, policies: dict[str, Policy]) -> None:
self._policies = policies
self._streak: dict[str, int] = {}
self._faulted: dict[str, bool] = {}
def faulted(self, name: str) -> bool:
return self._faulted.get(name, False)
def update(self, status: HealthStatus) -> str | None:
policy = self._policies.get(status.name, DEFAULT_POLICY)
prior = self._streak.get(status.name, 0)
streak = max(prior, 0) + 1 if status.ok else min(prior, 0) - 1
self._streak[status.name] = streak
if not self.faulted(status.name) and -streak >= policy.fail_polls:
self._faulted[status.name] = True
return "fault"
if self.faulted(status.name) and streak >= policy.clear_polls:
self._faulted[status.name] = False
return "clear"
return None
FLAPPING = (84.6, 85.4, 84.9, 85.7, 84.8, 86.2, 87.4, 88.1, 89.0, 88.6,
84.9, 83.5, 83.9, 83.1, 82.8)
if __name__ == "__main__":
d, raw = Debouncer(POLICIES), 0
print("poll memory raw event")
for i, pct in enumerate(FLAPPING, start=1):
status = HealthStatus("memory", pct < MEM_WARN_PCT, f"{pct:.1f}% used", pct)
event = d.update(status)
raw += 0 if status.ok else 1
print(f"{i:>4} {pct:>6.1f} {'ok' if status.ok else 'FAIL':<5} {event or '-'}")
print(f"\nmessages without a debouncer: {raw}")
$ uv run python -m labs.health_monitor
poll memory raw event
1 84.6 ok -
2 85.4 FAIL -
3 84.9 ok -
4 85.7 FAIL -
5 84.8 ok -
6 86.2 FAIL -
7 87.4 FAIL -
8 88.1 FAIL -
9 89.0 FAIL -
10 88.6 FAIL fault
11 84.9 ok -
12 83.5 ok -
13 83.9 ok -
14 83.1 ok -
15 82.8 ok clear
messages without a debouncer: 7
Fifteen minutes of readings, seven of them over the line, and exactly two events. The trick is the signed streak: a passing poll makes it positive and counts up, a failing poll makes it negative and counts down, and either one resets the other. Polls 2 and 4 are real threshold crossings and produce nothing, because a single sample on the far side of a line is not evidence of anything. Polls 6 through 10 are five failures in a row, and the fifth is the one the policy was waiting for.
fail_polls and clear_polls are separate numbers so you can
make one direction harder than the other. They are equal here and unequal in most real
deployments, where a fault clears slowly on purpose so a subsystem that recovers for
ninety seconds and dies again does not send you a recovery notice it has to take back.
The cost of all this is stated plainly: five polls at sixty seconds each means the
monitor is five minutes late to every real fault. That is the trade, and
ollama takes the other side of it with fail_polls=2, because
two minutes of her being unable to answer at all matters more than a rare false alarm.
One rule for waking a human
# labs/health_monitor.py — the standing monitor
from labs.alerts import LEVEL_ORDER, alert
POLL_SECONDS = 60.0
HEARTBEAT_POLLS = 60
def judge(status: HealthStatus, event: str, history: History) -> tuple[str, str]:
policy = POLICIES.get(status.name, DEFAULT_POLICY)
if event == "clear":
return "INFO", f"{status.name} recovered: {status.detail}"
level, parts = policy.level, [f"{status.name} degraded: {status.detail}"]
rate = history.rate_per_hour(status.name)
if rate is not None:
parts.append(f"trending {rate:+.2f}/hour")
hours = history.hours_until(status.name, policy.limit) if policy.limit else None
if hours is not None:
hours = round(hours, 1)
parts.append(f"{hours} h from {policy.limit:.0f}")
if policy.urgent_hours is not None and hours < policy.urgent_hours:
level = "CRITICAL"
return level, ", ".join(parts)
class Monitor:
"""History, hysteresis and severity for one machine."""
def __init__(self, policies: dict[str, Policy] | None = None) -> None:
self.history = History()
self.debounce = Debouncer(policies or POLICIES)
self._sent: dict[str, str] = {}
def observe(self, at: float, status: HealthStatus) -> tuple[str, str] | None:
self.history.record(at, status)
event = self.debounce.update(status)
if event == "clear":
self._sent.pop(status.name, None)
return judge(status, "clear", self.history)
if not self.debounce.faulted(status.name):
return None
level, message = judge(status, "fault", self.history)
sent = self._sent.get(status.name)
if sent is not None and LEVEL_ORDER.index(level) <= LEVEL_ORDER.index(sent):
return None
self._sent[status.name] = level
return level, message
def poll(self, at: float) -> tuple[list[HealthStatus], int]:
checks, spoken = run_checks(), 0
for status in checks:
verdict = self.observe(at, status)
if verdict is not None:
alert(*verdict)
spoken += 1
return checks, spoken
def main(interval: float = POLL_SECONDS) -> None:
monitor, n = Monitor(), 0
alert("INFO", f"health monitor up, polling every {interval:.0f}s")
while True:
at = time.time()
checks, spoken = monitor.poll(at)
n += 1
if spoken or n % HEARTBEAT_POLLS == 1:
print(render(checks, at), flush=True)
time.sleep(max(0.0, interval - (time.time() - at)))
if __name__ == "__main__":
main()
observe holds the rule that decides who gets woken. A fault sends one
message at the policy's level. While it stays faulted it sends nothing more, unless the
level it would send now outranks the level already sent, and the ranking comes from
chapter 16's ordered list of severities. So a disk that crosses its threshold reports
once at WARNING, goes quiet, and speaks a second time only when the trend says the
drive is full inside a day. Two messages for one problem, hours apart, each one
carrying information the previous one did not have. Note the round before
the comparison: the number printed in the message is the same number the rule tested,
so no one has to work out why a log line reading 24.0 hours escalated under a
twenty-four hour threshold.
Two details in the loop earn their lines. HEARTBEAT_POLLS prints one
full report an hour when nothing is happening, so a quiet journal still proves the
monitor is running; without it, a monitor that silently died and a monitor with nothing
to report look identical from the outside. The sleep subtracts the time the poll itself
took, because otherwise a three-second Ollama timeout makes every cycle 63 seconds
long, and a day that should hold 1,440 samples holds about 1,370 instead. Sleeping for
the interval and sleeping until the next interval are different instructions, and only
one of them keeps a schedule.
# /etc/systemd/system/glados-monitor.service
[Unit]
Description=GLaDOS health monitor
After=network-online.target
[Service]
Type=simple
User=glados
WorkingDirectory=/home/glados/GladOS
ExecStart=/home/glados/.local/bin/uv run python -m labs.health_monitor
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
$ ssh glados-jetson journalctl -u glados-monitor --since '2026-08-21 20:00' -o cat # captured on her Jetson; yours will differ
[20:00:07] health
ok ollama 6 model(s) available
ok disk 84.6% used, 35.9 GiB free
ok memory 44.1% used, 4.1 GiB available
all nominal
[WARNING] disk degraded: 85.0% used, 34.9 GiB free, trending +0.50/hour, 30.0 h from 100
[20:53:00] health
ok ollama 6 model(s) available
FAIL disk 85.0% used, 34.9 GiB free
ok memory 43.8% used, 4.2 GiB available
degraded: disk
[CRITICAL] disk degraded: 88.1% used, 27.7 GiB free, trending +0.50/hour, 23.8 h from 100
[03:02:00] health
ok ollama 6 model(s) available
FAIL disk 88.1% used, 27.7 GiB free
ok memory 41.6% used, 4.3 GiB available
degraded: disk
Six hours between those two alerts, and both are checkable against the numbers in them: 15.0 points below the limit at half a point an hour is 30.0 hours, and 11.9 points is 23.8. Between them the monitor polled roughly 370 times and said nothing, because nothing it learned in those six hours changed what a person would do about it. That silence is the feature. The line that finally wakes someone arrives with a deadline attached, at an hour when there is still time to act on it.
The monitor runs as glados-monitor.service, not inside her voice loop. A
thread inside glados.service dies exactly when she does, which is the
moment its report matters most, and it would compete for the same GIL that her
transcription is using. Two units also means two independent restart policies, and
Restart=always here is deliberate: the monitor should come back even when
it exits cleanly, since a health monitor that ended on purpose is still a machine with
nobody watching it.
Why this works: a band instead of a line
A threshold is a line, and a reading near a line crosses it constantly. Put the memory alarm at 85 percent on a box that idles at 84.9 and every poll is a coin flip. If each poll independently lands on the wrong side with probability p, then demanding N consecutive polls before the fault is declared drops the false-alarm odds to p to the power N. At a coin flip and five polls that is one spurious fault per 32 polls instead of one per two. Real readings are correlated, so the true number is worse than the arithmetic promises; the direction is what matters, and the direction is steep.
What the debouncer builds is a band around the line, wide enough that noise cannot get across it. Your thermostat does the same thing and for the same reason, holding the heating off until the room is a degree past the setpoint so the compressor is not switching on and off every forty seconds. Electronics calls the same trick a Schmitt trigger. The general form travels well beyond monitoring: any time a continuous measurement drives a discrete decision, the decision needs either a band or a minimum dwell time, or it will chatter.
The other half of the design is that a check is a pure function from the world right now to a value, and the monitor is a state machine over the stream of those values. Keeping those apart is why each piece stays testable. The checks touch the machine and hold no state, so you can call them from a loop, a web route, a voice command or a cron job with identical results. The state machine holds all the memory and touches nothing, so you can feed it fifteen invented percentages, as stage 5 does, and watch every transition it will ever make without waiting fifteen minutes or filling a real disk.
The obvious first version alerts whenever a check reports a failure. No streaks, no state, twelve fewer lines:
def poll(self, at: float) -> tuple[list[HealthStatus], int]:
checks = run_checks()
for status in checks:
if not status.ok: # BUG: no memory of the last poll
alert(POLICIES[status.name].level, f"{status.name}: {status.detail}")
return checks, 0
$ ssh glados-jetson journalctl -u glados-monitor --since 02:40 --until 02:56 -o cat
[WARNING] memory: 85.4% used, 1.1 GiB available
[WARNING] memory: 85.7% used, 1.1 GiB available
[WARNING] memory: 86.2% used, 1.0 GiB available
[WARNING] memory: 87.4% used, 0.9 GiB available
[WARNING] memory: 88.1% used, 0.9 GiB available
[WARNING] memory: 89.0% used, 0.8 GiB available
[WARNING] memory: 88.6% used, 0.8 GiB available
Seven messages in fifteen minutes, describing one model finishing one long answer, and with escalation armed at WARNING every one of them is an email at 2:40 in the morning. The symptom is easy to read once you know what to look for: the timestamps are one minute apart and the readings barely move, which is the signature of a threshold comparison run against a sample that is sitting on the threshold. The check is doing its job perfectly. The bug is that "this poll failed" was mistaken for "something changed".
The second failure is the one that actually costs you, and no code causes it. After the third such night you write a mail rule, or you stop opening the folder. Two nights later the disk fills for real, the CRITICAL lands in the muted folder beside two hundred memory warnings, and the first thing anyone notices is her own journal the next morning:
$ ssh glados-jetson journalctl -u glados --since '2026-08-23 03:00' -o short | tail -4
Aug 23 03:11:44 glados-jetson python[1613]: Traceback (most recent call last):
Aug 23 03:11:44 glados-jetson python[1613]: File "labs/long_term_memory.py", line 77, in store_fact
Aug 23 03:11:44 glados-jetson python[1613]: conn.commit()
Aug 23 03:11:44 glados-jetson python[1613]: sqlite3.OperationalError: database or disk is full
A monitor that cries wolf gets muted, and a muted monitor is worse than none at all, because the version with no monitor at least leaves you expecting nothing. Every message a monitor sends spends a little of a person's attention, and attention does not refill overnight. Debouncing, escalating only upward, and the hourly heartbeat are all the same policy from different angles: say less, and be believed when you do speak.
Checkpoint, and a system that can describe itself
- I can name a failure that a systemd restart cannot fix, and say what the monitor knows about it that the supervisor does not.
- I can explain why a failed check records no value instead of zero, and what a zero would do to the next twelve hours of trend.
- I can take a printed percentage and a printed rate and compute the hours remaining myself, then check the monitor's answer against mine.
- I can trace the signed streak through a flapping reading and name the exact poll where the fault fires and where it clears.
- I can state both costs of raising
fail_polls, and defend givingollamaa different number fromdisk. - I know why a second message about the same fault is sent only when the severity climbs, and what an inbox looks like without that rule.
- I can say why
MemFreeis the wrong field and whatMemAvailablecounts that it does not.
Exercise 1 — the fan that stopped. Add
check_thermal() reading the Jetson's thermal zones, give it a policy, and
watch the trend catch a heat problem before the throttle does.
Every zone under /sys/class/thermal/ exposes a temperature in
thousandths of a degree Celsius. Take the hottest one, since that is the one that
throttles:
from pathlib import Path
TEMP_WARN_C = 80.0
def check_thermal() -> HealthStatus:
try:
temps = [int(p.read_text()) / 1000
for p in Path("/sys/class/thermal").glob("thermal_zone*/temp")]
hottest = max(temps)
except (OSError, ValueError) as e:
return HealthStatus("thermal", False, f"unreadable: {e.__class__.__name__}")
return HealthStatus("thermal", hottest < TEMP_WARN_C,
f"{hottest:.1f} C across {len(temps)} zones", round(hottest, 1))
Add it to CHECKS and give it
Policy(fail_polls=3, clear_polls=3, level="WARNING", limit=95.0,
urgent_hours=1.0), since 95 C is where the board starts throttling itself.
Now block the intake with a book for a few minutes and watch the rate climb. A
stalled fan shows up as a steady positive slope long before any single reading is
alarming, and the alert arrives saying how many hours of headroom are left instead
of just that it is warm.
Exercise 2 — a history that survives a restart. The deque lives in memory, so restarting the monitor throws away twelve hours of trend. Persist each reading and reload the recent ones at startup.
Chapter 36's bounded appender keeps the file from growing forever:
append_bounded({"at": at, "name": s.name, "value": s.value}, HEALTH_LOG)
inside History.record, right after the deque append. Note what that
function actually does before you reach for it: it rewrites
glados/data/health.json as one indented array every call, keeping the
newest entries and discarding the rest, so the file stays bounded but the cost of a
write grows with the window. That is a fair trade at one poll a minute and a bad one
at one a second. On startup, read the array back, drop anything older than
HISTORY_POLLS minutes, and feed the rest into the series in timestamp
order.
Then test the part that matters. Fill the history, restart the process, and confirm
that rate_per_hour answers immediately instead of returning
None for two hours. That gap is the real bug this fixes: a monitor
restarted by a deploy is blind to slow failures for exactly as long as
MIN_TREND_HOURS says, and slow failures are the ones it exists to
catch.
Exercise 3 — let her answer the question. Add a
--once mode that runs one poll, exits 0 when nominal and 1 when degraded,
and speaks a one-line verdict in her own voice.
sys.exit(0 if all(c.ok for c in checks) else 1) makes the monitor
usable in a shell chain and as a systemd ExecStartPre, so she refuses
to start on a machine that is already sick. For the spoken half, build the core the
way chapter 86 does and hand it a summary:
failed = [c.name for c in checks if not c.ok]
core.speak("All systems nominal. Try not to sound so disappointed."
if not failed else
f"{len(failed)} subsystem(s) degraded: {', '.join(failed)}.")
Wire that to the voice command she already routes and "how are you feeling" stops being a joke and starts returning the disk trend. Run it once with the drive healthy and once with a large file filling the partition, and listen to the difference.
She now watches herself between conversations and speaks only when the answer changed. What no part of the stack can currently do is tell you what any of it is set to. Nine volumes have produced config keys, module paths, service names, thresholds like the five in this chapter, and the only record of them is the code itself and whatever you wrote down. The last chapter of this volume turns that record into a file that prints the truth every time you run it.