Repository navigation
2.0.7 — a dead background task is now a crash, not a zombie
A reliability release. One incident, one root cause, one class of bug — found by watching the 2.0.6 soak, not by a test.
The incident
A ~21-minute NAS upstream-connectivity outage killed name resolution for every container on 2026-09-05, 11:53:45–12:14:13 UTC. Pi-hole was not at fault: it was up 8 days and logged Connection error against 1.0.0.1, 208.67.222.222 and 8.8.8.8 simultaneously.
cli/observe, cli/news and cli/live failed soft and recovered. cli/operator did not — its notification forwarder died at 11:57:20 and stayed dead for 10h11m while the process stayed alive and its Discord transport reconnected at 12:26. Discord notification forwarding and the heartbeat-alert monitor for every other daemon were dark for that window. Trading was unaffected.
The container reported unhealthy 591 consecutive times and nothing acted on it, because restart: unless-stopped fires on process exit, not on healthchecks.
Root cause
aiohttp.ClientConnectorDNSError is not a discord.DiscordException — its MRO ends ... → ClientError → OSError. The Kraken adapter wraps its HTTP library's base error (except httpx.HTTPError); the Discord adapter wrapped only discord.py's domain error. That single asymmetry is why four daemons survived and one died.
The raw error passed straight through a correct, pre-existing per-row handler, out of the poll loop, out of a bare finally that logged it at INFO, and into a Task nobody awaited — where, because the Task stayed referenced by a local, even Python's "Task exception was never retrieved" warning never fired.
Fixed
- Seven adapter catch sites widened, not six. The seventh is
start()'s login handler, which the first draft of the plan missed — a boot during an outage would still have leaked raw aiohttp into the gateway task. - A loop that DIES logs ERROR; only a loop that STOPS stays INFO.
- Five background tasks supervised, not four. The omitted one was the Discord gateway — the worst zombie of the set, because
except DiscordTransportErrorguardedawait stop_event.wait(), so the module's ownexit_code = 1was already unreachable for exactly the failure it was written to catch. - The shutdown cancel loop no longer aborts on the first corpse.
cli/webhad the same narrow-catch bug with a worse consequence — an escape skippedsafe_shutdownentirely. Deliberately no exit-on-death there.cli/operator's exit codes are documented for the first time.
Process
The plan was reviewed before implementation: 51 agents, 5 dimensions, 15 findings, 7 surviving three-lens refutation, and a completeness critic that overturned a kill the panel made twice. It found the plan wrong in five of its six layers — including a prescription that raised TypeError on the repo's own interpreter while the correct pattern sat 976 lines above it in the same file.
All eight behaviours are mutation-verified: reverted in turn, guarding tests required to go red, tree asserted byte-identical after each restore. 8/8 caught. 3924 tests pass.
Known gap, stated rather than closed
The healthcheck still has no actor. This release converts task-death into a process exit that unless-stopped already handles; it does nothing for the "wedged-but-alive" class. Deferred to the reserved 2.1.0 phase, along with the fact that the heartbeat monitor watching all six daemons lives inside the one that died.
Full detail in CHANGELOG.md and roadmap receipt 17.