Full-day service run
Ninety-eight chapters, and not one full day
Everything she can do has been done with you sitting in front of it. You started the recorder and said the wake word. You watched the model answer, watched the arm move, read the health report off the terminal, then pressed ctrl-C and went to dinner. The longest she has ever been asked to run unsupervised is about as long as it takes to make a coffee.
A day is a different question. Sixteen hours is long enough for a model to be loaded, unloaded and loaded again, for a sensor to fire when the house is empty, for two subsystems to want the same gigabyte in the same second, and for all of that to happen in a room nobody is standing in. Whatever occurs in those hours, you find out about it afterwards or you do not find out at all.
Record scheduled jobs and incoming events, then start only the non-motion services for an unattended run. Keep arm commands disabled without an operator; service restart does not establish physical joint position or authorize motor startup.
The coming back is the part with something new in it. Her record is already complete and already scattered: the journal for her service, the journal for her monitor, and rows in a SQLite file, three sources written by three programs that have never heard of each other. Reading them together is the one skill this chapter teaches, and it is the skill that separates owning a machine from having built one.
memory.db. The chain only exists once all three are on one clock.
The retained captures below come from the earlier day configuration on a Jetson with
her two units enabled, a broker on the
network and a passive infrared sensor in a hall. Without any of that the day still
runs: replace the motion event with
mosquitto_pub -t home/hall/motion/event -m detected typed from a laptop,
and the event enters the same handlers. The corrected reference below keeps MiDaS
out of the day process and allows only one hallway watch, so it should not reproduce
the old model-loading incident. No new bench capture accompanies that change.
The review tool at the end needs
nothing but journalctl and her database, so it works against any machine
you have been running her on, including the laptop from volume 1.
Two tables, and everything else is machinery
# labs/day.py
"""Her day: what the clock asks of her, what the house asks of her, one log."""
import sqlite3
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from labs.autonomous import ScheduledTask, Scheduler # chapter 19
from labs.behavior_engine import Behavior, BehaviorEngine # chapter 46
from labs.event_bus import bus # chapter 15
from labs.health_monitor import run_checks # chapter 92
from labs.long_term_memory import recall_summaries # chapter 11
from labs.memory_distill import distill, ensure_schema # chapter 98
from labs.mqtt_bridge import say # chapter 90
DB_PATH = Path("glados/data/memory.db")
TICK_SECONDS = 1.0
def log(line: str) -> None:
"""One line to stdout. Under systemd, that is one line in the journal."""
print(f"[{time.strftime('%H:%M:%S')}] {line}", flush=True)
def speak(line: str) -> None:
"""Her voice, and one journal line recording what came out of it."""
log(f'said "{line}"')
say(line)
@dataclass(frozen=True)
class Slot:
at: str # "HH:MM" on the wall clock
name: str
fn: Callable[[], None]
why: str
def morning_brief() -> None:
log("slot morning_brief")
with sqlite3.connect(DB_PATH) as conn:
recent = recall_summaries(conn, limit=1)
speak(recent[0] if recent else "Nothing happened yesterday. Congratulations.")
def evening_check() -> None:
log("slot evening_check")
unhappy = [c.name for c in run_checks() if not c.ok]
speak("Everything is nominal. I find that suspicious." if not unhappy
else f"{', '.join(unhappy)} is unhappy. You were told.")
def nightly_distill() -> None:
log("slot nightly_distill")
with sqlite3.connect(DB_PATH) as conn:
ensure_schema(conn)
result = distill(conn)
log(f"distilled {result['covered']} exchanges, kept {len(result['written'])} facts")
DAY = [
Slot("06:30", "morning_brief", morning_brief,
"reads back what last night's pass kept"),
Slot("17:30", "evening_check", evening_check,
"one health report, spoken, whether or not anyone is in"),
Slot("23:15", "nightly_distill", nightly_distill,
"turns the day's interactions into rows you can correct"),
]
HOUSE = [
Behavior("Watch the hall", "hall/motion", "watch.hall", priority=3,
description="Ten minutes of depth, so she knows what went past."),
Behavior("Announce a visitor", "hall/motion", "announce.visitor", priority=2,
description="Ask the house specialist whether anyone is there."),
Behavior("Note an open window", "window/open", "note.window", priority=1,
description="Say it once. The sensor will keep repeating itself."),
Behavior("Emergency halt", "emergency", "halt.all", priority=5,
description="Stop every moving part, then explain."),
]
def minutes(hhmm: str) -> int:
hours, mins = hhmm.split(":")
return int(hours) * 60 + int(mins)
def gaps() -> list[tuple[str, str, int]]:
"""Every stretch of the day with nothing on the clock in it."""
times = sorted(minutes(s.at) for s in DAY)
out = []
for start, end in zip(times, times[1:] + [times[0] + 24 * 60]):
out.append((f"{start // 60:02d}:{start % 60:02d}",
f"{end % (24 * 60) // 60:02d}:{end % 60:02d}", end - start))
return out
def plan() -> None:
print(f"her day: {len(DAY)} slots on the clock, {len(HOUSE)} behaviours on the house")
for slot in DAY:
print(f" clock {slot.at} {slot.name:<16} {slot.why}")
for b in sorted(HOUSE, key=lambda b: -b.priority):
print(f" house p{b.priority} {b.trigger:<16} {b.action}")
start, end, span = max(gaps(), key=lambda g: g[2])
print(f" longest unscheduled stretch: {start} to {end}, "
f"{span // 60} h {span % 60:02d} m")
$ uv run python -m labs.day --plan
her day: 3 slots on the clock, 4 behaviours on the house
clock 06:30 morning_brief reads back what last night's pass kept
clock 17:30 evening_check one health report, spoken, whether or not anyone is in
clock 23:15 nightly_distill turns the day's interactions into rows you can correct
house p5 emergency halt.all
house p3 hall/motion watch.hall
house p2 hall/motion announce.visitor
house p1 window/open note.window
longest unscheduled stretch: 06:30 to 17:30, 11 h 00 m
Seven rows, and between them they are her entire agenda. Neither table is a new
invention. ScheduledTask and Scheduler come from chapter 19
unchanged; Behavior and BehaviorEngine come from chapter 46
unchanged, priority field and all. Each of the three slot functions is four lines
that call something an earlier chapter finished: last night's summary, the health
checks, the distillation pass. What is new is only that both lists are short,
complete and printable, so "what will she do today" has an answer you read in nine
lines instead of inferring from source code.
The last line is the one to sit with. Eleven hours of her day have nothing scheduled in them, which means eleven hours in which every action she takes will be triggered by the house or by you, and none of it is predictable from this table. A schedule is the small half of unattended operation. The record is what covers the rest.
# labs/day.py — continued
from glados.depth import ALERT_LEVEL, SharpnessDepth, something_close # chapter 95
from glados.vision import capture_frame # chapter 86
from labs.agent_team import SPECIALISTS, respond # chapter 91
from labs.arm_control import RoboticArm # chapter 69
from labs.arm_driver import ChannelDriver # chapter 69
from labs.arm_reach import plan_reach # chapter 94
from labs.automation import register_action # chapter 35
from labs.depth_cost import pool_gb, used_gb # chapter 95
from labs.mqtt_bridge import build_client, dispatch # chapter 90
from labs.self_learning import log_interaction # chapter 21
WATCH_SECONDS = 600.0
WATCH_STEP = 5.0
WATCH_LOCK = threading.Lock()
ARM = RoboticArm(ChannelDriver())
ENGINE = BehaviorEngine(HOUSE)
SPECIALISTS["house"]["keywords"].update({"mug", "arm", "fetch"})
def fact(key: str) -> str | None:
with sqlite3.connect(DB_PATH) as conn:
row = conn.execute("SELECT value FROM facts WHERE key = ?", (key,)).fetchone()
return row[0] if row else None
@register_action("fetch_mug")
def fetch_mug(text: str) -> None:
x, y = (float(v) for v in fact("mug_spot").split(","))
pose, refused = plan_reach(x, y)
if pose is None:
speak(f"I cannot reach that. {refused[0]}.")
return
log(f"mug_spot ({x:.1f}, {y:.1f}) -> shoulder {pose.shoulder_deg:.2f} "
f"elbow {pose.elbow_deg:.2f}")
ARM.reach(pose.shoulder_deg, pose.elbow_deg, 90.0)
ARM.grab()
log(f"arm {ARM.pose()}")
def _watch_hall() -> None:
try:
# This day admits no MiDaS weights beside chat, even if MiDaS is available.
source = SharpnessDepth()
log(f"hall watch (sharpness only) armed for {WATCH_SECONDS:.0f} s, "
f"{used_gb():.2f} GB of {pool_gb():.2f} in use")
close, ends = 0, time.monotonic() + WATCH_SECONDS
while time.monotonic() < ends:
if something_close(source.depth(capture_frame())):
close += 1
time.sleep(WATCH_STEP)
log(f"hall watch done: {WATCH_SECONDS:.0f} s, {close} frames past {ALERT_LEVEL}, "
f"{used_gb():.2f} GB in use")
finally:
WATCH_LOCK.release()
def watch_hall(context: dict) -> None:
"""One watch owns capture for its full lifetime; repeated events do not spawn more."""
if not WATCH_LOCK.acquire(blocking=False):
log("hall watch already active; event coalesced")
return
try:
threading.Thread(target=_watch_hall, daemon=True).start()
except BaseException:
WATCH_LOCK.release()
raise
def announce_visitor(context: dict) -> None:
question = "Is the hall camera showing anyone?"
answer = respond(question)
with sqlite3.connect(DB_PATH) as conn:
log_interaction(conn, question, answer)
speak(answer)
def note_window(context: dict) -> None:
speak("A window is open. The heating is, of course, still running.")
def halt_all(context: dict) -> None:
ARM.relax()
speak("Everything has stopped. You are welcome.")
# labs/day.py — the runner systemd starts
def on_house_event(context: dict) -> None:
matched = ENGINE.evaluate(context)
log(f"house {context['topic']}: "
f"{', '.join(b.name for b in matched) or 'nothing matched'}")
ENGINE.execute(matched, context)
def on_house_message(client, userdata, msg) -> None:
"""Chapter 90's broker callback, publishing instead of dispatching."""
bus.publish("house_event", {"topic": msg.topic, "payload": msg.payload.decode()})
def on_request(text: str) -> None:
"""One spoken turn, through the loop that has existed since volume 1."""
log(f'heard "{text}"')
speak(respond(text))
def run_day() -> None:
for action, handler in (("watch.hall", watch_hall),
("announce.visitor", announce_visitor),
("note.window", note_window),
("halt.all", halt_all)):
ENGINE.register(action, handler)
scheduler = Scheduler()
for slot in DAY:
scheduler.add(ScheduledTask(name=slot.name, fn=slot.fn, at_time=slot.at))
bus.subscribe("house_event", lambda c: dispatch(c["topic"], c["payload"]))
bus.subscribe("house_event", on_house_event)
bus.subscribe("speech_detected", lambda d: on_request(d["text"]))
client = build_client()
client.on_message = on_house_message
client.loop_start()
plan()
while True:
scheduler.tick()
time.sleep(TICK_SECONDS)
if __name__ == "__main__":
import sys
plan() if "--plan" in sys.argv else run_day()
$ ssh glados-jetson 'journalctl --list-boots | tail -2; uptime -p' # measured on the bench — yours will vary
-1 8f1c4d7a Sat 2026-08-22 07:02:11 CDT—Sun 2026-08-23 05:57:52 CDT
0 4a90b13e Sun 2026-08-23 05:58:03 CDT—Sun 2026-08-23 23:41:19 CDT
up 17 hours, 43 minutes
$ ssh glados-jetson journalctl -b -u glados --since '2026-08-23 05:55' --until '2026-08-23 07:15' -o short-iso # measured on the bench — yours will vary
2026-08-23T05:58:31-0500 glados-jetson systemd[1]: Started GLaDOS assistant.
2026-08-23T05:58:44-0500 glados-jetson python[1613]: her day: 3 slots on the clock, 4 behaviours on the house
2026-08-23T06:30:00-0500 glados-jetson python[1613]: [06:30:00] slot morning_brief
2026-08-23T06:30:07-0500 glados-jetson python[1613]: [06:30:07] said "You agreed to three things yesterday and did one of them. Good morning."
2026-08-23T07:12:41-0500 glados-jetson python[1613]: [07:12:41] heard "hand me the mug"
2026-08-23T07:12:41-0500 glados-jetson python[1613]: picked house (facts 0, house 1, recall 0)
2026-08-23T07:12:46-0500 glados-jetson python[1613]: [07:12:46] mug_spot (9.5, 3.0) -> shoulder 66.47 elbow 110.82
2026-08-23T07:12:48-0500 glados-jetson python[1613]: [07:12:48] arm shoulder= 66.5 elbow= 110.8 wrist= 90.0 gripper= 90.0
2026-08-23T07:12:48-0500 glados-jetson python[1613]: house chose 'fetch_mug', ran True
2026-08-23T07:12:53-0500 glados-jetson python[1613]: [07:12:53] said "There. Try not to spill it on anything I have to look at."
Nobody asked for that boot. Read the two boot records: the earlier one ends at
05:57:52 with no shutdown line in front of it, and the next begins eleven seconds
later. A clean reboot writes its intentions down first, while a power cut leaves a
journal that stops mid-sentence, and the only reason there is a second row at all is
the systemctl enable from volume 8. Her first act of the day was coming
back without being told to, and the first thing in the record is the proof.
The 07:12 exchange is the whole book in eight seconds. She heard you across the kitchen, transcribed it locally, scored the words and routed to the house specialist, got one action name back from a model on the same board, checked that name against the registry, read the mug's position out of her own facts table, solved a triangle for it and drove two servos to 66.47 and 110.82 degrees. Every one of those numbers is checkable: 9.5 across and 3.0 up is 9.96 cm from the shoulder pivot, which the law of cosines turns into an elbow bend of 110.82, and the arm's own report of 66.5 and 110.8 says nothing was clamped on the way in.
Two lines gave her that ability, and both are in stage 2. The decorator registers a
handler, so extract_action will accept the name; the
keywords.update teaches chapter 91's router that "mug" is a house word,
so the request reaches the specialist that can issue actions at all. Adding a
capability to her is now a handler and a word.
on_house_message is the one piece of new plumbing in the runner.
Chapter 90 wired its broker callback straight into dispatch, so a
message arriving from the network went to the action registry and nowhere else,
and nothing inside her could hear the house at all. Publishing it onto chapter
15's bus instead costs one line and changes who is allowed to listen:
dispatch becomes one subscriber among several, the behaviour engine
becomes another, and one motion event can turn a light on and start a depth watch
without either rule knowing the other is there.
watch_hall is the design decision to remember. The behaviour engine runs
matched handlers in priority order on whatever thread called it, so a handler that
blocks for ten minutes blocks that caller and every later handler in the same
dispatch. A worker lets dispatch continue, but the old worker also let chat and
MiDaS load together at noon. The reference now keeps ownership for the worker's
whole lifetime and admits only model-free sharpness estimates. Repeated events
don't spawn another watch. python -m unittest labs.test_day_policy
checks that ownership and its failure paths offline; it does not test a camera
or measure memory on a Jetson.
Noon, with the house empty
#!/usr/bin/env bash
# labs/day_logs.sh — the two units she runs, over one window
set -euo pipefail
host="${GLADOS_HOST:-glados-jetson}"
for unit in glados glados-monitor; do
printf '== %s\n' "$unit"
ssh "$host" journalctl -u "$unit" --since "$1" --until "$2" -o cat
done
$ bash labs/day_logs.sh '2026-08-23 12:35' '2026-08-23 12:55' # measured on the bench — yours will vary
== glados
[PUB] home/hall/lights/set = ON
[12:39:14] house home/hall/motion/event: Watch the hall, Announce a visitor
picked house (facts 0, house 1, recall 0)
[12:39:18] hall watch armed for 600 s, 5.20 GB of 7.44 in use
[12:39:44] said "The house specialist gave me nothing: no answer within 30s"
[12:49:18] hall watch done: 600 s, 4 frames past 0.75, 5.20 GB in use
== glados-monitor
[CRITICAL] memory degraded: 94.6% used, 0.4 GiB available, trending +8.16/hour, 0.7 h from 100
[12:44:00] health
ok ollama 6 model(s) available
ok disk 71.4% used, 66.9 GiB free
FAIL memory 94.6% used, 0.4 GiB available
degraded: memory
[INFO] memory recovered: 69.9% used, 2.2 GiB available
[12:50:00] health
ok ollama 6 model(s) available
ok disk 71.4% used, 66.9 GiB free
ok memory 69.9% used, 2.2 GiB available
all nominal
Start with the first block. One motion event reached two subscribers: chapter 90's
dispatch, which turned the hall light on and said so, and the behaviour
engine, which matched two rows and ran them in priority order. The
picked house line has no timestamp of its own because chapter 91's
respond printed it and knows nothing about this chapter's
log. Journald stamped it anyway, and -o cat is the format
that throws those stamps away.
The sentence she said at 12:39:44 is not a good sentence. It is the exact string
chapter 91 returns when ask_one hits its deadline, spoken at full volume
to an empty hallway, and it is in the record because she said it. Thirty seconds is
the budget and 12:39:14 plus thirty is 12:39:44, so the arithmetic agrees with the
log to the second. What this block does not say is why a request that normally takes
four seconds ran out of time.
The second block answers that, in numbers you can check. The board has 7.44 GB that Linux can see, so 94.6 per cent used leaves 0.40 GB, which prints as 0.4. The trend runs from the first reading of the boot, 39.5 per cent at 05:58:44, to this one at 12:44:00: 55.1 points across 6.75 hours is 8.16 points an hour, and the remaining 5.4 points at that rate is 0.7 hours. The memory policy escalates inside two hours of its limit, so a warning became a CRITICAL.
That deadline is fiction and the reading is not. Memory really was at 94.6 per cent,
and chapter 92's slope is drawn through two endpoints, so a step of fifty-five points
in five minutes is reported as a smooth climb since breakfast. Act on the first half
of the sentence and discount the second. Then look at the ollama row,
which says six models are available throughout, including on the poll taken while her
own request to that same server was timing out. The check asks
/api/tags, which lists model files on disk. Nothing about that question
involves loading weights, so it answers instantly whether or not the server can
currently think. A cheap probe proves a process is alive. It never proves the work is
getting done.
Putting the day back together
# labs/day_review.py
"""Rebuild any stretch of a day from the records she already keeps."""
import re
import sqlite3
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
DB_PATH = Path("glados/data/memory.db")
UNITS = ("glados", "glados-monitor")
JOURNAL = re.compile(r"^(\S+) \S+ [\w.-]+\[\d+\]: (.*)$")
BODY = re.compile(r"^\s")
@dataclass(frozen=True)
class Entry:
at: datetime # timezone-aware, always, without exception
source: str
text: str
def journal(unit: str, since: str, until: str) -> list[Entry]:
"""Everything one unit said, stamped by journald in local time."""
out = subprocess.run(
["journalctl", "-u", unit, "--since", since, "--until", until,
"-o", "short-iso"],
capture_output=True, text=True, check=True).stdout
entries = []
for line in out.splitlines():
match = JOURNAL.match(line)
if match is None: # "-- Boot 4a90b13e --" and friends
continue
entries.append(Entry(datetime.fromisoformat(match.group(1)).astimezone(),
unit, match.group(2)))
return entries
def interactions(conn: sqlite3.Connection, since: datetime,
until: datetime) -> list[Entry]:
"""Her side of every exchange. Chapter 21 has stored these in UTC all along."""
entries = []
for created_at, asked, answered in conn.execute(
"SELECT created_at, user_input, response FROM interactions ORDER BY id"):
at = datetime.fromisoformat(created_at).astimezone()
if since <= at <= until:
entries.append(Entry(at, "memory.db", f"{asked!r} -> {answered!r}"))
return entries
Two readers, two wildly different sources, one return type, and that is the entire design. A subprocess and a database cursor have nothing in common except that both can produce an instant, a name and a string. Once they do, everything downstream is list handling.
Both parsers call .astimezone() at the moment they build an
Entry, and neither one ever hands a string forward. Journald's
short-iso stamps carry an offset, so fromisoformat returns
an aware value and astimezone normalises it to this machine's zone. The
database rows carry +00:00, so the same two calls do the same job from a
different starting point. That is the boundary rule this chapter runs on: convert at
the edges, work in one representation in the middle.
# labs/day_review.py — continued
def timeline(since: str, until: str) -> list[Entry]:
low, high = (datetime.fromisoformat(s).astimezone() for s in (since, until))
entries: list[Entry] = []
for unit in UNITS:
entries += journal(unit, since, until)
with sqlite3.connect(DB_PATH) as conn:
entries += interactions(conn, low, high)
return sorted(entries, key=lambda e: e.at)
def main(since: str, until: str, full: bool = False) -> None:
rows = [e for e in timeline(since, until) if full or not BODY.match(e.text)]
previous = None
for entry in rows:
gap = "" if previous is None else f"+{(entry.at - previous).total_seconds():.0f}s"
print(f"{entry.at:%H:%M:%S} {gap:>8} {entry.source:<15} {entry.text}")
previous = entry.at
span = (rows[-1].at - rows[0].at).total_seconds() if rows else 0.0
print(f"{len(rows)} lines, {span:.0f} s, "
f"sources: {', '.join(sorted({e.source for e in rows}))}")
if __name__ == "__main__":
import sys
main(sys.argv[1], sys.argv[2], full="--full" in sys.argv)
$ uv run python -m labs.day_review 2026-08-23T12:35:00 2026-08-23T12:55:00
12:39:14 glados [PUB] home/hall/lights/set = ON
12:39:14 +0s glados [12:39:14] house home/hall/motion/event: Watch the hall, Announce a visitor
12:39:14 +0s glados picked house (facts 0, house 1, recall 0)
12:39:18 +4s glados [12:39:18] hall watch armed for 600 s, 5.20 GB of 7.44 in use
12:39:44 +26s glados [12:39:44] said "The house specialist gave me nothing: no answer within 30s"
12:39:44 +0s memory.db 'Is the hall camera showing anyone?' -> 'The house specialist gave me nothing: no answer within 30s'
12:44:00 +256s glados-monitor [CRITICAL] memory degraded: 94.6% used, 0.4 GiB available, trending +8.16/hour, 0.7 h from 100
12:44:00 +0s glados-monitor [12:44:00] health
12:49:18 +318s glados [12:49:18] hall watch done: 600 s, 4 frames past 0.75, 5.20 GB in use
12:50:00 +42s glados-monitor [INFO] memory recovered: 69.9% used, 2.2 GiB available
12:50:00 +0s glados-monitor [12:50:00] health
11 lines, 646 s, sources: glados, glados-monitor, memory.db
Eleven lines, and the incident reads straight down the page. One sensor event matched two behaviours. The lower-priority one asked the chat model a question, which made Ollama start loading two gigabytes of weights. The higher-priority one had already handed a ten-minute depth watch to a thread, and four seconds later that thread reported the depth model up and the board at 5.20 of 7.44 GB. The two loads overlapped, each one slowing the other, and only one of them had a deadline. Everything after that is consequence: a sentence spoken to nobody, a row in the database, five failing polls, an alert, and a return to normal when the model server dropped the weights it had finished with.
The +256s and +318s columns carry as much as the lines do.
Four minutes and sixteen seconds passed between her failure and the alert, because
five consecutive failing polls is what chapter 92's Debouncer requires
before it will wake anyone. That delay is correct, and it is also a fact you now know
about your own machine: her worst minute is over before the monitor has finished
deciding there is a problem. A monitor is for trends and for things that stay broken.
It was never going to catch a thirty-second event, and her own log did.
One number contradicts the story you would tell from memory. She recovered to 69.9 per cent, not to the 39.5 she woke up with. The depth model never went away, because chapter 95 loads it once per process and keeps it there deliberately, so from noon onward the board carries 2.3 GB it was not carrying at breakfast. The next time both models want the pool in the same second, the same eleven minutes happen again. No single log line says that. The comparison between two log lines does, and that is the reason to merge them.
-o cat gives you the message and nothing else, which is right for
reading and useless for merging. -o short-iso prefixes every line with
2026-08-23T12:39:14-0500, an offset with no colon in it, which
datetime.fromisoformat accepts on Python 3.11 and rejects on 3.10 and
earlier with ValueError: Invalid isoformat string. The workspace pinned
3.11 in chapter 1, so this works as written; run the tool somewhere older and you
will be inserting that colon by hand. -o short-iso-precise adds
microseconds and parses the same way, which starts to matter the day two events land
in the same second and you need to know which came first.
Why this works: three witnesses, one clock
Nothing in day_review.py knows anything about GLaDOS. It runs a command,
reads a table, converts both to a common type, sorts, and prints. That is the whole
method, and the method works because of the one thing the three sources have in common:
each records a fact with a time attached, and none of them was designed with the other
two in mind. Correlation is rarely a feature you build into a system. It is something
you do to a system afterwards, and the only requirement is that every record carry an
unambiguous instant.
Unambiguous is doing real work in that sentence. A wall-clock string is an instant plus
a timezone you have to know from somewhere else, and the moment two programs disagree
about which one they meant, sorting them together gives a confident, ordered, wrong
answer. So Entry.at is an aware datetime and never a string,
both readers convert at the point of parsing, and the window bounds get converted
before anything is compared against them.
The second idea is about what a log line is for. Almost every line she writes has the
same two parts: a thing that happened, and a measurement that was true when it
happened. hall watch armed for 600 s, 5.20 GB of 7.44 in use would have
been perfectly readable without the memory figure, and without it the incident would be
unsolvable. Printing a number that is not obviously interesting at the time is the
cheapest insurance in this book, and the rule generalises past her: when an action
takes a resource, log the resource.
Third, and the one that keeps costing people evenings: a green dashboard is not a
statement about whether work is getting done. The ollama check is honest,
fast and cheap, and cheap is what makes it blind. Every monitoring system you will meet
has this seam in it, between probes that ask whether a thing is alive and probes that
ask whether it can do its job, and the second kind is expensive enough that almost
nobody runs it every minute. Her record answered the harder question anyway, because
her own failure was written down next to the health reading.
The first version of interactions filtered in SQL, which is the obvious
way to do it and one fewer row to read:
def interactions(conn: sqlite3.Connection, since: str, until: str) -> list[Entry]:
rows = conn.execute(
"SELECT created_at, user_input, response FROM interactions"
" WHERE created_at BETWEEN ? AND ? ORDER BY id", (since, until)).fetchall()
return [Entry(datetime.fromisoformat(c).astimezone(), "memory.db",
f"{a!r} -> {b!r}") for c, a, b in rows]
$ uv run python -m labs.day_review 2026-08-23T12:39:00 2026-08-23T12:40:00 # the first version
12:39:14 glados [PUB] home/hall/lights/set = ON
12:39:14 +0s glados [12:39:14] house home/hall/motion/event: Watch the hall, Announce a visitor
12:39:14 +0s glados picked house (facts 0, house 1, recall 0)
12:39:18 +4s glados [12:39:18] hall watch armed for 600 s, 5.20 GB of 7.44 in use
12:39:44 +26s glados [12:39:44] said "The house specialist gave me nothing: no answer within 30s"
5 lines, 30 s, sources: glados
No error, no empty-result warning, and a timeline that reads perfectly well. The only clue is the summary line: one source where there should be two. Her side of the exchange is missing, and without that line naming its sources you would have concluded she never logged the conversation at all. Ask the database directly:
$ ssh glados-jetson "sqlite3 glados/data/memory.db 'SELECT id, created_at FROM interactions ORDER BY id DESC LIMIT 3'"
418|2026-08-23T17:39:44.318204+00:00
417|2026-08-23T12:12:53.904772+00:00
416|2026-08-22T23:41:07.552310+00:00
$ ssh glados-jetson timedatectl | head -3
Local time: Sun 2026-08-23 12:58:11 CDT
Universal time: Sun 2026-08-23 17:58:11 UTC
Time zone: America/Chicago (CDT, -0500)
There it is, in the +00:00. Chapter 21's log_interaction
has stored datetime.now(timezone.utc) since volume 3, which was the
right decision and has been quietly true for eighty chapters. Journald stamps in
local time, and this bench runs five hours behind UTC in August. The row for 12:39:44
local is stored as 17:39:44, the query asked for text between
...T12:39:00 and ...T12:40:00, and SQLite compared those as
strings, one character at a time. 17:39 sorts after 12:40,
so the row was excluded by a comparison working exactly as designed.
Two mistakes stacked here and only one of them is about timezones. The first was
comparing timestamps as text. The second was filtering before converting, in a layer
that had no idea what zone either side was in. The fix in stage 4 reads every row,
parses each into an aware datetime, and filters afterwards, which is
slower and correct. A day of interactions is a few hundred rows and correctness costs
milliseconds. If the table ever grows past what you want to scan, add a normalised
column and index that, but never reach for a string range on a timestamp again.
Checkpoint, and the machine standing in your house
- I can name the two things that cause her to act, and for any line in her journal I can say which of the two produced it.
- I can read two boot records and tell a clean reboot from a power cut without anyone telling me which it was.
- I can explain why a request and a depth model ended up loading into the same pool at the same second, and what handing the watch to a thread bought and cost.
- Given a memory alert, I can check its percentage, its slope and its deadline against the readings by hand, and say which half of the sentence to act on.
- I can say why the
ollamacheck stayed green while her own request to that same server was timing out. - I can rebuild a twenty-minute window from a service journal, a monitor journal and a SQLite table, and be sure every row is in the order it happened.
Exercise 1 — review a day you have already lived. Run
day_review across the last twenty-four hours of your own machine, find
the longest silence in it, and decide whether that silence was correct.
Keep the largest gap:
max(zip(rows, rows[1:]), key=lambda p: p[1].at - p[0].at) hands you
the pair either side of the quietest stretch. On a healthy day the answer is the
overnight hours between the distillation slot and the morning brief, and finding
anything else is the point of the exercise. A four-hour silence in the afternoon
means either that nothing happened or that she stopped writing things down, and
the monitor's hourly report is what tells those apart: look for it inside the
gap. If it is missing, the silence is the incident.
Exercise 2 — account for model lifetimes. Compare the reference day's model-free watch with a design that alternates MiDaS and chat. Write an admission trace and a latency budget for each before any supervised model-only bench run. Keep motion disabled.
The implemented policy is deliberately restrictive: _watch_hall
constructs only SharpnessDepth, and one worker owns
WATCH_LOCK until it finishes or fails. Duplicate motion events
coalesce. This avoids loading MiDaS weights in the day process, at the cost of
chapter 95's easily fooled texture heuristic. It does not identify a visitor,
measure distance or bound all memory use. Stop any separate MiDaS job before
starting the day; other processes do not share this admission rule.
Alternating heavy models needs a lifetime owner, not just handler order.
Before admitting MiDaS, stop new chat admissions, wait for every in-flight
generation to finish, request unload of the already resident Ollama model
(an empty generate request with keep_alive: 0), and verify its
absence through Ollama's running-models API. Only then start the depth worker.
Before admitting chat again, stop that worker and verify it has released its
weights. A separate depth process with confirmed exit makes that boundary
easier to enforce than a cached object in a long-lived Python process.
A cleanup failure leaves admission closed. All clients must obey the owner;
a lock in these two handlers cannot control another service's model requests.
keep_alive: 0 on a generation unloads its model after that
generation; it cannot prevent overlap during the generation. Priority only
changes dispatch order, and a timed-out client can leave server work running.
PyTorch can retain allocations after a local reference disappears, while Ollama
retains models independently of a Python lock. These are the lifetimes the owner
must observe. This alternating owner is an exercise design, not an implemented
capability of day.py.
Give each policy its own latency target. With the model-free watch, measure the ordinary cold and warm chat response times; four seconds is a candidate budget, not a promised result. A non-preemptible ten-minute depth lease can delay a visitor announcement by ten minutes plus unload, chat load and generation time. A shorter lease or interruptible worker changes that trade. Record event arrival, admission, model release and response timestamps. A memory percentage below 85 at occasional monitor polls does not prove two models never overlapped; combine lifecycle records with model/process residency observations and state what the sampling could miss. Keep the observed record beside your build notes.
Exercise 3 (stretch) — teach the record to answer questions.
Add a --why HH:MM:SS flag that prints the ninety seconds leading up to
a moment, across every source.
Parse the argument into an aware datetime against today's date, call
timeline with bounds of moment - timedelta(seconds=90)
and moment, and print the result with the target line marked. Ninety
seconds is a sensible default because it is longer than her slowest turn and
shorter than a monitor poll, so you get every action and at most two health
readings.
Then point it at something that annoys you. The next time she says something odd,
note the clock, run --why against it, and read the ninety seconds
that produced the sentence. Fifteen lines of code turn her from a machine that
surprises you into a machine you can debug.
Go and look at what is standing in your house. She hears you from across a room and decides for herself when you have stopped talking. She transcribes you on silicon you own, scores the words to pick which of her specialists should handle them, thinks with weights sitting on a board you can unplug, and answers in a voice cloned from clips you gathered yourself. She remembers what you told her last Tuesday and can be made to forget it. She refuses actions she has no permission for and writes the refusal down. She drives an arm you printed, on brackets you calibrated, to angles she works out from a target and a triangle. She watches a hallway, reports her own health to a page in a browser, comes back after a power cut without being asked, and at a quarter past eleven every night she reads the day to herself and keeps the parts that mattered. There is no account behind any of it. Nothing you built can be switched off by a company that changes its mind about you.
All of it is yours to change, and the seams are exactly where you left them. Her personality is a JSON file you can edit at breakfast. Her behaviours are four rows in a list, her day is three. The joint bands are numbers you measured with a steel rule, the alert thresholds are a dict, her voice is a WAV file and a reference sentence, and the model she thinks with is one line in a config. Change any of them, run the checks, and she is different this afternoon. Most people who live with a machine like this cannot change a single word of what it says to them.
Record the remaining work: camera-to-arm calibration, a tested owner for alternating model lifetimes, and a suitable response when a specialist misses its deadline. Keep motion disabled during model-only experiments, and compare each change against the same board and workload.
She is running right now, while you read this, in a room you are not standing in. At 17:30 she will report her own health to an empty kitchen. At 23:15 she will turn today's conversations into rows you can correct in the morning. Tomorrow she will do it all again, whether or not anyone is watching, until you decide she should work differently and change her, which you can, because every line of her is on a disk in your house. Go and break something on purpose. She writes it all down.