GLaDOS Vol 8 · The Jetson Brain
ch 84 / 99
Chapter 84

Jetson Production Setup

She runs because you are logged in

The Jetson is doing everything it was bought to do. It transcribes on the GPU, thinks with a model chosen against a memory budget instead of a parameter count, drives the servos through the same 40-pin header the Pi drove, and answers about six times faster than the board it replaced. All of that is true only while an SSH session is open and you are the one typing. Close the laptop and she is still running. Pull the barrel jack out of the wall and plug it back in, and there is nothing: no model loaded, no capture stream, no light in the eye, and no error anywhere, because nobody started anything.

Three habits are keeping her alive right now, and every one of them is you. You run the voice loop by hand from the project directory. You set the power mode with nvpmodel after any experiment that touched it. You watch the temperatures climb during a long conversation and decide whether to open the cabinet door. None of the three announces itself when it stops happening. A throttled Jetson does not raise an error, it answers slower. A voice loop that never started does not crash, it simply fails to be there when somebody says her name at seven in the morning.

So the last chapter of this volume writes a setup script, and the important thing about it is what it refuses to do. A production script does not perform the work at every boot. It configures the machine to perform the work by itself, and then proves the configuration is still there after a reboot nobody supervised.

◆ Note — the bench for this chapter, and how to read its output

One Jetson Orin Nano Super developer kit on JetPack 6.2.2, root filesystem on the 500 GB NVMe drive, hostname glados-jetson, user glados, and an x86 laptop with none of the Jetson's files on it. Every mode name, temperature and timestamp below came off that one board on one warm evening in a room with the door shut, and yours will differ in all of them. The commands and the code are what carry over. Anything printed by the board is labelled where it appears.

Three ways a step can fail on a machine you cannot assume

▣ Build · stage 1 — every outcome of running a command, as data
# labs/jetson_production.py
import subprocess
from dataclasses import dataclass


@dataclass(frozen=True)
class Cmd:
    """One attempt to run a command, with all of its outcomes flattened into data."""
    argv: list[str]
    found: bool
    returncode: int | None
    stdout: str
    stderr: str

    @property
    def ok(self) -> bool:
        return self.found and self.returncode == 0

    def why(self) -> str:
        if not self.found:
            return f"{self.argv[0]}: not on PATH"
        if self.returncode != 0:
            msg = self.stderr.strip() or self.stdout.strip() or "no message"
            return f"{self.argv[0]}: exit {self.returncode}: {msg}"
        return f"{self.argv[0]}: ok"


def run(argv: list[str]) -> Cmd:
    """Never raises. A missing binary is an answer, not an accident."""
    try:
        proc = subprocess.run(argv, text=True, capture_output=True, check=False)
    except FileNotFoundError:
        return Cmd(argv, False, None, "", "")
    return Cmd(argv, True, proc.returncode, proc.stdout, proc.stderr)


if __name__ == "__main__":
    for argv in (["nvpmodel", "-q"], ["nvpmodel", "-m", "9"]):
        print(run(argv).why())
$ ssh glados-jetson python3 -m labs.jetson_production   # on the board
nvpmodel: ok
nvpmodel: exit 255: Error: Invalid power mode 9
$ uv run python -m labs.jetson_production   # the same file, on the laptop
nvpmodel: not on PATH
nvpmodel: not on PATH

Those two transcripts are the whole reason this wrapper exists. subprocess.run has two entirely separate ways of reporting trouble, and only one of them is an exit code. When the binary exists and complains, you get a CompletedProcess with a non-zero returncode, and check=False is what stops that from raising. When the binary is not on PATH at all, no process was ever created, so there is no exit code to check and subprocess raises FileNotFoundError before your code sees anything. Setting check=False does nothing about the second case, and a setup script that only handles the first dies on the laptop at the first Jetson-only command.

A third failure is coming later in the chapter and it does not involve subprocess at all: writing into /etc as an ordinary user raises PermissionError from the filesystem. Missing command, failing command, refusing filesystem. Those are the three, and each one wants its own handler, because a script that catches Exception around all of them can no longer tell you which of the three happened.

▣ Build · stage 2 — a power mode chosen by name, and recorded for the next boot
# labs/jetson_production.py — continued
import re
from pathlib import Path

NVPMODEL_CONF = Path("/etc/nvpmodel.conf")
NVPMODEL_STATUS = Path("/var/lib/nvpmodel/status")
WANTED_MODE = "MAXN_SUPER"

MODE_LINE = re.compile(r"<\s*POWER_MODEL\s+ID\s*=\s*(\d+)\s+NAME\s*=\s*(\S+)\s*>")


def mode_table(conf: Path = NVPMODEL_CONF) -> dict[str, int]:
    """Every power mode this board declares, by name, read off the board itself."""
    try:
        text = conf.read_text(errors="replace")
    except OSError:
        return {}
    return {name: int(ident) for ident, name in MODE_LINE.findall(text)}


def recorded_mode(status: Path = NVPMODEL_STATUS) -> int | None:
    """The mode nvpmodel wrote down, which is the one the next boot restores."""
    try:
        fields = dict(f.split(":", 1) for f in status.read_text().split())
    except (OSError, ValueError):
        return None
    return int(fields["pmode"]) if "pmode" in fields else None


def set_power_mode(name: str = WANTED_MODE) -> str:
    table = mode_table()
    if not table:
        return "no nvpmodel.conf here, so this is not a Jetson: skipped"
    if name not in table:
        return f"{name} is not a mode this board declares: {sorted(table)}"
    wanted = table[name]
    if recorded_mode() == wanted:
        return f"already {name} (mode {wanted}), recorded for the next boot"
    cmd = run(["nvpmodel", "-m", str(wanted)])
    if not cmd.ok:
        return cmd.why()
    return f"set to {name} (mode {wanted}); recorded mode is now {recorded_mode()}"


if __name__ == "__main__":
    print(f"modes on this board: {mode_table()}")
    print(f"power mode: {set_power_mode()}")
$ ssh glados-jetson sudo python3 -m labs.jetson_production   # first run
modes on this board: {'15W': 0, '7W': 1, 'MAXN_SUPER': 2}
power mode: set to MAXN_SUPER (mode 2); recorded mode is now 2
$ ssh glados-jetson sudo python3 -m labs.jetson_production   # again, after a reboot
modes on this board: {'15W': 0, '7W': 1, 'MAXN_SUPER': 2}
power mode: already MAXN_SUPER (mode 2), recorded for the next boot

The number in nvpmodel -m 2 is the part everybody copies from a forum post and the part that is not portable. Mode identifiers are assigned per board and per JetPack release, so the same 2 means MAXN_SUPER here, something else on an AGX, and nothing at all on a board whose configuration file lists three modes in a different order. The name is stable and the file that maps names to identifiers is already on the disk, so the script reads it. That is the same instinct as chapter 82's detect_platform: ask the machine what it is instead of telling it.

The second run is the one that matters for production. nvpmodel writes the chosen mode into /var/lib/nvpmodel/status, and a service that ships with JetPack reads that file during boot and re-applies it, so the mode survives a power cut without any help from you. That makes this step a one-time configuration and not a boot chore, and it also makes the mode something you can quietly break: one nvpmodel -m 1 typed during an experiment in June is still in effect in August. Reading the recorded value back is how the script tells the difference between a board set correctly and a board that merely was, once.

Heat you asked for, and heat the board imposes

▣ Build · stage 3 — the temperatures, and the kernel's own limit
# labs/jetson_production.py — continued
THERMAL = Path("/sys/devices/virtual/thermal")


def temperatures() -> dict[str, float]:
    """Every thermal zone the kernel exposes, in degrees Celsius."""
    zones: dict[str, float] = {}
    for zone in sorted(THERMAL.glob("thermal_zone*")):
        try:
            name = (zone / "type").read_text().strip()
            milli = int((zone / "temp").read_text().strip())
        except (OSError, ValueError):
            continue
        zones[name] = milli / 1000
    return zones


def trip_c(zone_name: str) -> float | None:
    """The lowest trip point the kernel declares for a zone, in degrees Celsius."""
    for zone in THERMAL.glob("thermal_zone*"):
        try:
            if (zone / "type").read_text().strip() != zone_name:
                continue
            trips = [int(p.read_text()) / 1000 for p in zone.glob("trip_point_*_temp")]
        except (OSError, ValueError):
            continue
        return min(trips) if trips else None
    return None


def thermal_line() -> str:
    zones = temperatures()
    if not zones:
        return "no thermal zones here: skipped"
    name, temp = max(zones.items(), key=lambda kv: kv[1])
    trip = trip_c(name)
    if trip is None:
        return f"hottest {name} {temp:.1f} C, no trip point declared"
    return f"hottest {name} {temp:.1f} C, {trip - temp:.1f} C below the {trip:.1f} C trip point"


if __name__ == "__main__":
    for zone_name, celsius in temperatures().items():
        print(f"{zone_name:<13} {celsius:.1f} C")
    print(f"thermals: {thermal_line()}")
$ ssh glados-jetson python3 -m labs.jetson_production   # measured on the bench, yours will vary
cpu-thermal   46.5 C
gpu-thermal   45.2 C
cv0-thermal   44.9 C
soc2-thermal  47.8 C
tj-thermal    48.1 C
thermals: hottest tj-thermal 48.1 C, 43.9 C below the 92.0 C trip point

The power mode and the temperature answer two different questions and people run them together. MAXN_SUPER is a ceiling you asked for: it tells the board how much power it may draw and how high it may clock. The trip point is a ceiling the board imposes: cross it and the kernel drops clocks until the junction cools, with no log line in your journal and no exception in your Python. On the open bench with a fan over it, this board sat forty degrees clear of that limit. Inside a closed cabinet in a warm room, the margin is the number that changed, and the only symptom you would ever notice is that she started answering more slowly than the benchmark in chapter 80 said she should. Recording the margin the day you install the box gives you something to compare against on the day she feels sluggish.

▣ Build · stage 4 — one path, made true, twice in a row
# labs/jetson_production.py — continued
DECLARED_STORE = Path("/mnt/nvme/ollama/models")          # what the config says
OLLAMA_STORE = Path("/usr/share/ollama/.ollama/models")   # where Ollama really writes


def link_model_store(declared: Path = DECLARED_STORE,
                     store: Path = OLLAMA_STORE) -> str:
    """Make the path the config declares resolve to the directory Ollama uses."""
    if not store.is_dir():
        return f"{store} is missing: is Ollama installed?"
    if declared.is_symlink():
        if declared.resolve() == store.resolve():
            return f"{declared} already resolves to {store}"
        return f"{declared} points at {declared.resolve()} instead: left alone"
    if declared.exists():
        return f"{declared} exists and is not a link: left alone, move it yourself"
    declared.parent.mkdir(parents=True, exist_ok=True)
    declared.symlink_to(store)
    return f"{declared} -> {store}"


if __name__ == "__main__":
    print(f"model store: {link_model_store()}")
$ ssh glados-jetson sudo python3 -m labs.jetson_production   # first run, then immediately again
model store: /mnt/nvme/ollama/models -> /usr/share/ollama/.ollama/models
model store: /mnt/nvme/ollama/models already resolves to /usr/share/ollama/.ollama/models

Chapter 82's override table declares model_storage as /mnt/nvme/ollama/models for this board, and until now no such path existed. Ollama installs as a system user and keeps weights under /usr/share, which on this board is already the NVMe drive JetPack was flashed onto in chapter 76, so nothing here is about speed. It is about there being one path in the project that names where models live. The day a second drive goes into the carrier, one link moves and no Python changes.

Read the guards in order, because their order is the design. is_symlink comes before exists on purpose: exists follows a link and reports on the target, so a link that dangles answers False to exists while still being a link you must not silently overwrite. A real directory sitting at that path means somebody put files there, so the function refuses and says so instead of deleting anything. Running the script five times in a row leaves the machine in the same state as running it once, and no run of it can destroy data. That is the whole meaning of a setup script being safe to re-run.

Handing the loop to systemd without touching the generated unit

Chapter 54 already built the unit. render_unit reads the component registry, works out which units she has to start after, fills in this machine's user, working directory and interpreter, and can diff what it would generate against what is installed. That unit is correct on the Jetson exactly as it is, and editing it here would be the worst possible move: the next run of --check would report a difference and you would have no way to tell a Jetson addition from a stale deployment.

◆ Note — how a drop-in merges, and the one directive that bites

systemd reads /etc/systemd/system/glados.service, then every .conf file in /etc/systemd/system/glados.service.d/, in filename order. Directives that hold a list, including ExecStartPre, After and Environment, get appended to. Directives that hold a single value, such as RestartSec, get replaced. The exception that catches everyone is ExecStart: it is a list, so a drop-in adds a second one and the unit refuses to load with Service has more than one ExecStart setting, which is only allowed for Type=oneshot services. Clearing a list takes an empty assignment first, ExecStart= on its own line. This drop-in never needs that, because it only adds.

▣ Build · stage 5 — the board-specific half, in its own file
# labs/jetson_production.py — continued
import socket
import sys

from labs.service_unit import PROJECT_ROOT

DROPIN_DIR = Path("/etc/systemd/system/glados.service.d")
DROPIN_PATH = DROPIN_DIR / "jetson.conf"
ASSERT_MODE = [sys.executable, "-m", "labs.jetson_production", "--assert-mode"]
ON_FAILURE = PROJECT_ROOT / "glados" / "on_failure.py"

DROPIN_TEMPLATE = """# Generated by labs/jetson_production.py on {host}. Do not edit by hand.
[Unit]
StartLimitIntervalSec=120
StartLimitBurst=10
OnFailure=glados-alert.service

[Service]
ExecStartPre={assert_mode}
RestartSec=2
WatchdogSec={watchdog_sec}
NotifyAccess=main
Environment=OLLAMA_MODELS={declared}
"""


def render_dropin(watchdog_sec: int = 90) -> str:
    return DROPIN_TEMPLATE.format(
        host=socket.gethostname(),
        assert_mode=" ".join(ASSERT_MODE),
        watchdog_sec=watchdog_sec,
        declared=DECLARED_STORE,
    )


def install_dropin() -> str:
    content = render_dropin()
    try:
        DROPIN_DIR.mkdir(parents=True, exist_ok=True)
        DROPIN_PATH.write_text(content)
    except PermissionError:
        print(f"\n--- write this to {DROPIN_PATH} yourself ---\n{content}---")
        return "no permission: re-run with sudo, or copy the block above"
    return f"wrote {DROPIN_PATH} ({len(content.splitlines())} lines)"
$ ssh glados-jetson cat /etc/systemd/system/glados.service.d/jetson.conf
# Generated by labs/jetson_production.py on glados-jetson. Do not edit by hand.
[Unit]
StartLimitIntervalSec=120
StartLimitBurst=10
OnFailure=glados-alert.service

[Service]
ExecStartPre=/home/glados/GladOS/.venv/bin/python -m labs.jetson_production --assert-mode
RestartSec=2
WatchdogSec=90
NotifyAccess=main
Environment=OLLAMA_MODELS=/mnt/nvme/ollama/models

Twelve lines, eight of them directives, and every directive is a claim that is only true on this board. The appended ExecStartPre runs after chapter 33's health_check and refuses to let her start on a board that came up in the wrong power mode, so a throttled machine announces itself at boot instead of hiding behind a slow reply. WatchdogSec=90 asks systemd to kill and restart the process if it stops sending heartbeats for a minute and a half. OnFailure names a unit to run when this one gives up, and StartLimitIntervalSec with StartLimitBurst is the pair the worked failure below is about. The PermissionError branch is the third failure kind from stage 1, handled the way a setup script should handle it: print the exact file and its exact contents, so a reader who forgot the sudo can finish by hand in ten seconds.

▣ Build · stage 6 — the escalation unit, and the driver
# glados/on_failure.py — systemd runs this when glados.service gives up
import socket

from labs.alerts import alert

alert("CRITICAL", f"glados.service failed on {socket.gethostname()}")
# labs/jetson_production.py — the entry point
def assert_power_mode(name: str = WANTED_MODE) -> int:
    table = mode_table()
    if not table:
        return 0                       # not a Jetson: nothing to assert
    recorded, wanted = recorded_mode(), table[name]
    if recorded == wanted:
        return 0
    print(f"power mode is {recorded}, expected {wanted} ({name})", file=sys.stderr)
    return 1


def main() -> int:
    if "--assert-mode" in sys.argv:
        return assert_power_mode()
    print("=" * 58)
    print("  Jetson production setup")
    print("=" * 58)
    print(f"  power mode : {set_power_mode()}")
    print(f"  thermals   : {thermal_line()}")
    print(f"  model store: {link_model_store()}")
    print(f"  drop-in    : {install_dropin()}")
    print("-" * 58)
    print("  sudo systemctl daemon-reload")
    print("  sudo systemctl enable --now glados")
    print("  journalctl -u glados -b -f")
    print("=" * 58)
    return 0


if __name__ == "__main__":
    sys.exit(main())
$ ssh glados-jetson sudo /home/glados/GladOS/.venv/bin/python -m labs.jetson_production
==========================================================
  Jetson production setup
==========================================================
  power mode : already MAXN_SUPER (mode 2), recorded for the next boot
  thermals   : hottest tj-thermal 48.1 C, 43.9 C below the 92.0 C trip point
  model store: /mnt/nvme/ollama/models already resolves to /usr/share/ollama/.ollama/models
  drop-in    : wrote /etc/systemd/system/glados.service.d/jetson.conf (12 lines)
----------------------------------------------------------
  sudo systemctl daemon-reload
  sudo systemctl enable --now glados
  journalctl -u glados -b -f
==========================================================
$ ssh glados-jetson sudo reboot; sleep 90; ssh glados-jetson 'systemctl is-enabled glados; systemctl is-active glados; uptime -p'
enabled
active
up 1 minute

The second transcript is the only proof this chapter accepts. Not that the script ran, not that enable printed a symlink, but that the power was interrupted and the box came back by itself with the service running. Do it with the monitor unplugged and the SSH session closed, then walk over and say her name.

Three supervisors nested by what each one outlives Three nested rectangles. The outermost is the machine across power cuts, held up by systemctl enable and the recorded nvpmodel status file, and it can only see whether the unit came up at all. Inside it is the process across crashes, held up by Restart, WatchdogSec and OnFailure, seeing exit codes and silence. Inside that is the pipeline across a stalled stage, held up by the watchdog thread from chapter 41, which is the only layer that knows which stage went quiet. EACH LAYER SEES ONLY WHAT IT OUTLIVES the machine, across a power cut systemctl enable · the recorded nvpmodel mode the process, across a crash Restart · RestartSec · WatchdogSec · OnFailure the pipeline, across a stalled stage the watchdog thread, inside the running loop the only layer that knows WHICH stage went quiet
Figure 84.1 — Detail falls off as you move outward and survival rises. The innermost layer knows the most and dies with the process; the outermost knows almost nothing and is the only one still there after the power comes back.

Why this works: three supervisors, three lifetimes

Chapter 41 put a watchdog thread inside the process. It watches heartbeats per stage, so when transcription stops answering it can name the stage and fire a recovery without disturbing anything else. It is also the layer with the shortest life: it dies the instant the process it lives in dies, and it cannot see a Python interpreter that hung on a deadlock, because a hung interpreter is not running the watchdog either.

systemd is the supervisor one scope out. It knows two things about her and nothing else: whether the process exists, and what number it exited with. That is a poor view of a voice assistant and a perfect view of a service. WatchdogSec closes the gap the thread cannot cover, because heartbeats now cross the process boundary through sd_notify, and a process too stuck to send them gets killed by something that is not stuck. Restart handles a crash; enable handles a boot; and OnFailure handles the case where systemd itself has decided to stop trying, which is the case that would otherwise be silent.

The outermost layer survives the power going away, and almost nothing lives out there: a symlink in /etc that enable created, and a mode identifier in a status file. Both are one-time writes that later boots read. Generalise the rule past this board and it decides where any piece of supervision belongs: a mechanism can only watch what it outlives, so put each check in the innermost layer that survives the failure it is meant to catch. Stage-level stalls belong in the thread. Crashes and hangs belong in the service manager. Anything that has to be true after a power cut has to be written to a file that survives one.

⚠ Worked failure — the box came back, gave up, and told nobody

The first version of the drop-in carried RestartSec=2 and nothing else. Ten seconds felt slow for a box that should be listening by the time you reach the kitchen, so two seconds it was. It tested perfectly: kill the process, and she is back before you can run systemctl status. Then the power went out at four in the morning, came back forty seconds later, and at eight she was dead.

$ ssh glados-jetson journalctl -u glados -b --no-pager | tail -8
Aug 21 04:12:03 glados-jetson systemd[1]: glados.service: Control process exited, code=exited, status=1/FAILURE
Aug 21 04:12:03 glados-jetson systemd[1]: glados.service: Failed with result 'exit-code'.
Aug 21 04:12:05 glados-jetson systemd[1]: glados.service: Scheduled restart job, restart counter is at 4.
Aug 21 04:12:07 glados-jetson systemd[1]: glados.service: Control process exited, code=exited, status=1/FAILURE
Aug 21 04:12:07 glados-jetson systemd[1]: glados.service: Scheduled restart job, restart counter is at 5.
Aug 21 04:12:07 glados-jetson systemd[1]: glados.service: Start request repeated too quickly.
Aug 21 04:12:07 glados-jetson systemd[1]: glados.service: Failed with result 'exit-code'.
Aug 21 04:12:07 glados-jetson systemd[1]: Failed to start GLaDOS voice assistant.

Work through it from the top. A control process is an ExecStartPre, not the loop itself, and status 1 from the startup probe means it found something wrong: the USB microphone had not finished enumerating yet, which on this board takes about fifteen seconds after a cold boot. That is a transient, and transients are what Restart is for. The counter reaching 5 in four seconds is where the transient turned permanent. systemd's default allowance is five starts inside ten seconds, and RestartSec=2 spends all five of them in eight. So it gave up at 04:12:07, roughly seven seconds before the microphone would have appeared, and the unit sat in failed for four hours.

Two changes, and each fixes a different half. Widen the allowance so fast retries are not the same thing as a short deadline, and name something to run when the allowance is spent anyway:

# the [Unit] half of the drop-in, corrected
[Unit]
StartLimitIntervalSec=120
StartLimitBurst=10
OnFailure=glados-alert.service
$ ssh glados-jetson journalctl -u glados -b --no-pager | tail -4   # after the change
Aug 21 21:40:12 glados-jetson systemd[1]: glados.service: Scheduled restart job, restart counter is at 7.
Aug 21 21:40:14 glados-jetson glados[1613]: startup check: 6/6 passed
Aug 21 21:40:14 glados-jetson systemd[1]: Started GLaDOS voice assistant.
Aug 21 21:40:26 glados-jetson glados[1613]: listening.

Ten tries across two minutes outlasts a slow USB bus, and the seventh attempt is the one that worked. Notice that both directives live under [Unit] and not [Service], which is where people put them and where systemd ignores them with a warning most readers scroll past. The second half matters more than the first. Any restart policy has a limit somewhere, and reaching it means the machine has stopped trying and nothing in the house knows. OnFailure runs glados/on_failure.py, which calls chapter 16's alert at CRITICAL and puts a line in the durable log and a message wherever escalation is armed. A box that fails loudly at 04:12 is a different morning from a box that is simply not there at eight.

Checkpoint, and a volume that ends with a box on a shelf

✓ Checkpoint — what you can now do
  • I can name the three ways a setup step fails on a machine whose state it cannot assume, and say which of them check=False has any effect on.
  • I can explain why a power mode identifier copied from a forum post is unsafe, and read the name-to-identifier table off the board instead.
  • I can say which file makes a Jetson power mode survive a power cut, and prove the mode came back by reading it rather than by trusting that I set it once.
  • Given a thermal zone and its trip points, I can state the margin the board is running with, and predict the symptom of crossing it.
  • I can write a setup step that is safe to run five times, including the guard order that keeps a dangling symlink from being mistaken for a missing path.
  • I can read a start-limit message in the journal and say how many attempts were made, in what window, and which directive would have bought more of them.
⚡ Exercises — try first, then reveal
Exercise 1 — an audit that reads the box back. Add --audit to main(): no writes at all, one line per configured thing, and a non-zero exit if any of them drifted.

Four questions, all answerable without changing anything: is the recorded mode the wanted one, does the declared store resolve to Ollama's store, is the drop-in on disk and identical to render_dropin(), and does run(["systemctl", "is-enabled", "glados"]) come back enabled. Collect the failures into a list and exit 1 when it is non-empty.

$ ssh glados-jetson python3 -m labs.jetson_production --audit; echo "exit $?"
  OK    power mode    MAXN_SUPER (mode 2) recorded
  OK    model store   /mnt/nvme/ollama/models resolves to Ollama's store
  DRIFT drop-in       on disk, but 2 lines differ from this project
  OK    boot enabled  systemctl is-enabled: enabled
  1 of 4 drifted
exit 1

That command belongs in a systemd timer once a week. It answers "is this box still configured the way I left it?" without anybody remembering to ask.

Exercise 2 — make the box tell you it is hot. Extend thermal_line into a check that warns below a chosen margin and escalates below a smaller one.

Pick two margins, say 20 C and 8 C, and route them through the levels chapter 16 already defines: above 20 C of headroom print and log nothing, between 20 and 8 call alert("WARNING", ...), below 8 call alert("CRITICAL", ...). Then close the cabinet door, run a long generation, and watch which one fires.

$ ssh glados-jetson python3 -m labs.jetson_production --thermal-check   # cabinet shut, mid-generation
hottest tj-thermal 76.4 C, 15.6 C below the 92.0 C trip point
WARNING thermal headroom 15.6 C on glados-jetson
exit 0

Sixteen degrees on a warm evening with the door shut is the measurement that tells you whether the enclosure needs a vent, and it is a number nobody can guess from the bench.

Exercise 3 — send the heartbeat systemd is waiting for. The drop-in asks for WatchdogSec=90, and nothing sends anything yet. Wire the running loop to the watchdog socket and watch a stuck process get killed.

systemd sets WATCHDOG_USEC and NOTIFY_SOCKET in the service's environment. Read the interval, halve it, and from the same thread that already takes heartbeats send WATCHDOG=1 to that socket. The sdnotify package does it in three lines, and a datagram to an AF_UNIX socket does it with no dependency at all.

$ ssh glados-jetson 'kill -STOP $(systemctl show -p MainPID --value glados)'   # then wait
Aug 21 22:07:41 glados-jetson systemd[1]: glados.service: Watchdog timeout (limit 1min 30s)!
Aug 21 22:07:41 glados-jetson systemd[1]: glados.service: Killing process 1613 (python) with signal SIGABRT.
Aug 21 22:09:12 glados-jetson systemd[1]: Started GLaDOS voice assistant.

SIGSTOP is the honest test, because a stopped process is the one thing an in-process watchdog can never catch: the watchdog thread is stopped too. Ninety seconds later the outer layer noticed and she came back.

Look at what this volume did. It started by refusing to describe the new board as a faster Pi and put both machines into a table whose deciding row was memory: one shared pool, twice as large, read eight times faster. Then JetPack went onto the NVMe drive and a verification script read the version files back, because a progress bar that reaches 100 percent is not evidence. First boot happened once at a monitor and never again. CUDA was checked in two independent layers, which is how a working GPU and a compiler missing from PATH turned out to be the same afternoon. Ollama was measured instead of trusted, and the measurement caught the fallback that install logs never mention. Five runs became one number that could be compared to the Pi's, and the comparison landed on 6.8 times the decode rate, almost exactly what the bandwidth row predicted. The Volume 7 pin map was rechecked pin by pin against a different GPIO chip under an identical header. The whole stack moved across in four table rows and no rewritten modules. A bigger model was chosen by memory budget and tokens per second. And today the board became a box: it boots into her, it holds its power mode, it says where its weights live, and it shouts when it gives up.

What she still cannot do is anything that needs a sharper sense of the room or of herself. She answers from a model's training and the last few turns of conversation, so the manual for your own furnace might as well not exist. She has an eye that lights up and no camera behind it. She listens for a fixed number of seconds whether you finished your sentence in two or are still talking at six. She waits for the entire reply to be generated before the first word is spoken, which on a longer answer is several seconds of silence while the box has already decided what it is going to say. Nothing outside this house can ask her anything, and nothing inside the house obeys her.

Volume 9 goes after all of it. Retrieval over documents you give her, so she can answer from your own knowledge instead of a stranger's. A camera behind the eye, and a model that describes what is in front of it. Voice activity detection to replace the fixed listening window with one that ends when you stop talking. Streaming, so she begins speaking while the model is still thinking. A REST API so other programs can reach her, device control so she can reach the house, teams of agents that split a request between them, and a health monitor that watches the whole thing the way this chapter's drop-in watches one service. The hardware is finished. From here the work is senses.