Acting Unasked
Initiative needs a clock
Everything she does is still a response. A wake word arrives, a command gets parsed, an answer goes out. A real assistant also acts on its own schedule: the morning briefing at 08:00, a memory checkpoint every thirty minutes, chapter 18's suggestions offered when the hour arrives instead of when you happen to speak. The missing organ is a scheduler, a loop that wakes itself, checks what is due, fires it, and goes back to sleep.
The naive version is a tight while True: that re-checks the clock as
fast as the CPU allows. That pegs a core at 100% for work needed once a minute, and
it has a nastier bug hiding in it: at 08:00 the condition "now equals 08:00" stays
true for the entire minute, so the briefing fires hundreds of times before the
clock ticks over. Bolt on sleep(60) and you trade one bug for its
mirror image; a task scheduled for 08:00 can be skipped entirely when the loop
happens to sleep across the minute.
The fix is two ideas working together. Tick on a short interval, a few seconds of sleep between checks, so no minute can slip past unobserved. And remember what already fired, so a task runs exactly once per scheduled slot no matter how many ticks land inside it. A scheduler is a cheap polling loop plus a memory of its own actions; the memory is what turns "fires whenever the condition is true" into "fires once."
A task, a ticker, a guard
# labs/autonomous.py
from dataclasses import dataclass, field
from typing import Callable, Optional
@dataclass
class ScheduledTask:
name: str
fn: Callable
interval_sec: Optional[float] = None
at_time: Optional[str] = None # "HH:MM", for time-of-day tasks
last_run: float = field(default_factory=lambda: 0.0)
last_at_fired: str = "" # which minute-slot already fired
def memory_checkpoint() -> None:
print("[TASK] Saving memory checkpoint.")
task = ScheduledTask(name="memory_checkpoint", fn=memory_checkpoint, interval_sec=30)
print(f"Created task '{task.name}': every {task.interval_sec}s, last_run={task.last_run}")
task.fn()
$ uv run python labs/autonomous.py
Created task 'memory_checkpoint': every 30s, last_run=0.0
[TASK] Saving memory checkpoint.
Chapter 2's dataclass, now holding a callable. The scheduler that comes next
never learns what memory_checkpoint does; it sees a function and a
due time, which keeps the machinery generic in exactly the way the automation
engine and the event bus were. Two fields are bookkeeping for the two firing
styles: last_run for intervals, last_at_fired for
time-of-day slots. The default of 0.0 is a quiet design choice with
a visible consequence: every interval task fires immediately on the first tick,
because "now minus zero" exceeds any interval. For a checkpoint task, firing
once at boot is a feature.
import time
class Scheduler:
def __init__(self) -> None:
self.tasks: list[ScheduledTask] = []
def add(self, task: ScheduledTask) -> None:
self.tasks.append(task)
def tick(self) -> None:
now = time.monotonic()
for task in self.tasks:
if task.interval_sec is not None:
if now - task.last_run >= task.interval_sec:
task.fn()
task.last_run = now
scheduler = Scheduler()
scheduler.add(ScheduledTask(name="memory_checkpoint", fn=memory_checkpoint,
interval_sec=1.0))
for i in range(5):
print(f"tick {i}")
scheduler.tick()
time.sleep(0.6)
$ uv run python labs/autonomous.py
tick 0
[TASK] Saving memory checkpoint.
tick 1
tick 2
[TASK] Saving memory checkpoint.
tick 3
tick 4
[TASK] Saving memory checkpoint.
Fires on ticks 0, 2 and 4, skips 1 and 3, and the arithmetic explains every line:
the interval is 1.0 seconds and each tick is 0.6 apart, so tick 1 arrives 0.6
seconds after the last fire (too soon), while tick 2 arrives at 1.2 (due). The
subtraction now - task.last_run is the entire interval engine. Note
which clock it reads: time.monotonic() only moves forward, at a
steady rate, immune to NTP corrections and daylight-saving jumps. Wall-clock
time (time.time()) can leap backward, and an elapsed-time
subtraction across that leap goes briefly negative. Durations get the monotonic
clock; "what time is it" gets the wall clock. Mixing them up works for months,
then fails on one specific night in autumn.
from datetime import datetime
# inside Scheduler.tick(), after the interval branch:
if task.at_time is not None:
slot = datetime.now().strftime("%H:%M")
if slot == task.at_time and task.last_at_fired != slot:
task.fn()
task.last_at_fired = slot
$ uv run python labs/autonomous.py # clock at 08:00, ticking every 2s
[TASK] Running morning briefing.
(28 more ticks inside 08:00 — silence)
The guard is two comparisons. The first asks "is it the scheduled minute?"; the
second asks "did I already fire for this exact slot?" Recording the slot string
in last_at_fired makes the firing idempotent within the minute:
thirty ticks land inside 08:00, one briefing runs. And because the slot value
changes tomorrow morning ("08:00" is the same string, but yesterday's fire
recorded today's slot only after firing), the comparison comes true again
exactly once per day. State plus condition, instead of condition alone: the
same repair you gave set_mood's silent failures a formal channel
for, now keeping a briefing from running three hundred times.
def run_forever(scheduler: Scheduler, tick_seconds: float = 2.0) -> None:
print("Scheduler running. Ctrl+C to stop.")
try:
while True:
scheduler.tick()
time.sleep(tick_seconds)
except KeyboardInterrupt:
print("\nScheduler stopped. She resents the interruption.")
if __name__ == "__main__":
s = Scheduler()
s.add(ScheduledTask(name="checkpoint", fn=memory_checkpoint, interval_sec=1800))
s.add(ScheduledTask(name="briefing", fn=lambda: print("[TASK] Morning briefing."),
at_time="08:00"))
run_forever(s)
$ uv run python labs/autonomous.py
Scheduler running. Ctrl+C to stop.
[TASK] Saving memory checkpoint.
^C
Scheduler stopped. She resents the interruption.
A two-second tick against a one-minute slot means roughly thirty observations of every minute: no slot can be missed, and the guard absorbs the extra twenty-nine. The CPU cost is one loop iteration every two seconds, a rounding error. This loop will eventually live beside the voice loop, and the polite way to run both is volume 4's problem; for now the scheduler is a second program you can leave running, which is precisely what "autonomous" means at this stage.
Why this works: poll cheap, fire once
Every scheduler in the world sits somewhere on one axis: how it waits. Interrupts and OS timers wake exactly on time at the cost of platform machinery; polling checks on its own schedule at the cost of some lag and some wasted checks. For household tasks with minute granularity, polling every two seconds buys perfect coverage for negligible cost, and the code fits on a page you fully understand. The idempotency guard is the other half, and it generalizes: any time a condition stays true across multiple observations ("it is 08:00", "the temperature is over 30", "the user is home"), acting on the condition alone acts repeatedly. Pairing the condition with a memory of the last action is how observers become actors without becoming stutterers. Chapter 18's once-per-hour suggestion limit was this guard; chapter 41's watchdog will be this guard with a harder job.
The time-of-day branch without its second comparison, which is how it always gets written first:
if slot == task.at_time: # no last_at_fired check
task.fn()
$ uv run python labs/autonomous.py # clock reaches 08:00
[TASK] Morning briefing.
[TASK] Morning briefing.
[TASK] Morning briefing.
[TASK] Morning briefing.
(...every two seconds, for the whole minute...)
The condition "the clock reads 08:00" holds for sixty seconds, the loop observes it thirty times, and the briefing runs thirty times. Wire this to her voice and she greets you thirty times; wire it to an email alert and chapter 16's flood exercise happens to you at dawn. The bug is invisible in testing when your test interval is long or your timing is lucky, and it detonates on schedule the first real morning. One field and one comparison fix it permanently. When you meet any scheduled system that fired twice (a cron job, a billing run, a notification), your first suspect is now this exact missing guard.
Checkpoint, on her own time
- I can name both classic scheduler bugs and the half of the fix that addresses each.
- I can explain the tick-0/2/4 output from the interval arithmetic without rerunning it.
- I know which clock measures durations, which answers "what time is it," and the autumn night that punishes confusing them.
- I can trace the idempotency guard through one 08:00 minute and say why it fires again tomorrow.
- I can place polling against interrupt-driven scheduling and defend polling for this job.
Exercise 1 — a real briefing. Give the 08:00 task a body: recall yesterday's summaries from chapter 11's store, today's top suggestion from chapter 18, and print both as one block. Which chapter's code did you have to change?
None. The briefing composes three finished modules through their public functions, and the scheduler runs whatever callable it is handed. When a new feature is assembled entirely from existing returns, that is the volume working as designed; the next step, speaking it in her voice, is one more import.
Exercise 2 — starve the tick. Set
tick_seconds=90 and schedule a task for two minutes from now.
Does it fire? Explain what you observe using the two bugs from the opening.
Sometimes, and that is the point: a 90-second sleep can jump the loop clean over the scheduled minute, so the task fires only when a tick happens to land inside it. You have reproduced the missed-minute bug on demand. The rule that falls out: the tick interval must be comfortably shorter than the smallest slot you match, and half of it is a safe ceiling.
Exercise 3 — persistence meets the guard. Restart the scheduler at 08:00:30, after the briefing already fired at 08:00:05. What happens, and what would fix it?
It fires again: last_at_fired lived in memory and died with the
process, so the fresh scheduler sees 08:00 and an empty guard. The fix is
chapter 11's move, persisting the guard state (a one-row table keyed by task
name), so restarts inherit what already happened. Idempotency across
restarts needs memory that survives restarts; every piece of this volume
keeps meeting the others.
She acts on her own schedule now, once per occasion, like something with manners. One chapter remains in her mind's construction: the requests she hears are getting too varied for one prompt to serve, and the answer is not a bigger brain. It is a committee.