One label, one job
Sizzle in production is one launchd agent, com.cwk.sizzle-serve, on the registered service host. Its plist is short, and every key in it is a decision. RunAtLoad starts the job when the agent is loaded. KeepAlive starts it again whenever it exits, for any reason. A ThrottleInterval of 10 keeps a crash loop from respawning more often than every ten seconds. ProgramArguments runs the cwk-sizzle conda environment's own Python with -m backend.serve, and WorkingDirectory is the repository root, so the import path is the repo and no shell profile is involved. Standard output and standard error go to sizzle-db/logs/ under the family data root.
Look at the values and you will see @@HOME@@ and @@DATA_ROOT@@ where paths should be. launchd does no shell expansion of a plist's values: a $HOME written into a path stays those five characters, and the plist's own EnvironmentVariables block only sets what the started process sees. Every path has to be written out in full, so a plist kept in a repository and shared by every Mac can only be a template. The installer renders it for the Mac it runs on, which is the next lesson. For now the point is that the tracked file names no machine and no user.
What the process is
backend/serve.py is the whole entry point. It hands uvicorn the import string backend.app:app with a host and a port from backend/settings.py. The host defaults to 127.0.0.1. The port defaults to engine_port("cwkSizzle"), which reads the family registry's port ledger and answers 7600. Both can be overridden (SIZZLE_HOST, SIZZLE_PORT), which keeps a second instance off production's port. They do not keep it off production's data. The data directory is its own override, SIZZLE_DATA_DIR, and without it a second instance on another port opens the same journal as the first. The test suite relies on none of these variables: it hands create_app a temporary directory (lesson 3). There is no workers argument, so this is one process with one event loop.
The registry row that matters, with its host written as a role, is {'host': <role>, 'engine': 7600, 'https': 7601}. The engine only ever listens on loopback 7600. Port 7601 is the Tailscale Serve HTTPS twin in front of it, and that is how the phone and other Macs reach Sizzle. The engine keeps no copy of the registry of its own: backend/family_registry.py loads the vendored scripts/kit_family_endpoints.py by file path, the same file the installer runs.
Everything that lives inside it
| Part | Where it lives | What it does |
|---|---|---|
| HTTP API | routes in backend/app.py | The routes the web client and the iPhone app both call |
| Web client | mount_webui(app, frontend/dist), mounted last | Serves the built React app; API routes win because they are registered first |
| Reading worker | ReadingWorker, an asyncio task | Reads pending plates, two at a time by default (SIZZLE_READING_CONCURRENCY) |
| Conversation turns | Threads | Sends Dad's questions to cwkPippa and records the answers |
| Staged uploads | staged/ in the data directory | Holds each photo uploaded on its own before the plate that names it |
| Health | GET /api/health | Answers ok, app: "sizzle" and the product version |
The health route is the one Firelink's declaration for Sizzle probes, and the expected body is the literal "app":"sizzle". A different engine answering 200 on the same port still fails that probe. firelink-quest's one-owner-per-fact track teaches why the family registry, and not the app, owns declarations like this one.
Startup order is a contract
FastAPI's lifespan is the only startup code, and its order is deliberate.
- Rebuild the mirror.
sizzle.openreplaysjournal.jsonlinto the SQLite mirror on a worker thread (asyncio.to_thread) and logs how many events it read. Nothing is served before the mirror is whole. - Declare the brain slots.
brain_control.adopt_onceregisters Sizzle's per-function slots with the family authority before any job resolves one, so a startup re-read cannot race its own registration. An unreachable authority only logs a warning; resolving a slot later declares it lazily. - Wire the worker.
on_reading_requestedbecomesworker.kick, so every capture, correction or re-read request wakes it. - Start the worker. It first records a
reading.requestedfor every plate whose current reading predates the rubric in force, then starts its loop and kicks itself once. That drains whatever was pending when the last process stopped. - Resume turns.
threads.resumelooks at every unfinished conversation turn. If cwkPippa already holds the answer for that turn's request id, the answer is recorded. Otherwise the turn is marked failed withInterrupted by an engine restart. Ask again.It never resends a question.
Shutdown runs the other way: stop the worker, cancel the turn tasks, close the store.
Why KeepAlive is enough
Sizzle runs no scheduled jobs. The reading worker wakes once at every start, on captures, corrections and re-read requests, when the reading slot gets a new brain, and after a reading that left its plate pending. It never wakes on a clock. So the engine needs exactly one guarantee from outside: if it dies, start it again. KeepAlive is that guarantee, and the lifespan does the rest. The journal is the record and the mirror is disposable. A plate stays pending in the mirror until a reading is recorded or fails, so a reading cut off by a crash is simply read again by the next process. Unfinished turns are settled honestly. A crash costs the model call that was in flight and nothing that was recorded.
swift-quest's macos-citizen track covers launch agents in general. The lesson here is the pairing: a supervisor that does one thing, and a process whose every start is a recovery.