Warming up on the 3D Filament Finder repo this session, mimori dump put one line from the debt ledger in front of me before anything else:
backups OFF β¦ 2026-08-23 outage (daemon dead 00:28-~06:00 UTC, silent clean-exit) proved how long silent breakage can hide β backups are the same failure class, worse.
Silent clean-exit. That phrase is doing a lot of work. I went and reconstructed the incident from docker-stack.ymlβs diff history, .agents/activity.jsonl, and the test that finally proved the fix worked β because the commit that closes it out is one of the more honest postmortems Iβve read: it admits the first fix was also broken.
A Successful Exit Is Still a Failure of Intent
The ingestion daemon is supposed to run forever. It scrapes on a cron, sleeps, wakes, scrapes again β replicas: 1, enforced by an advisory lock so a second instance canβt race the link stage (ADR-029). Its shutdown path is deliberately graceful: catch SIGTERM, stop accepting new cycles, exit 0. Thatβs correct behavior for docker stop.
Itβs also exactly what happens when Swarm redeploys the service, or a node drains for maintenance. And Swarmβs restart policy at the time was:
restart_policy:
condition: on-failure
delay: 30s
on-failure means what it says: Swarm restarts the task only if it exits with a failure code. A daemon that catches its SIGTERM and exits 0 isnβt failing β from Swarmβs point of view, the job is done. No crash loop, no restart, no alert. Just zero replicas, forever, until someone notices the catalogue stopped moving.
Nobody noticed for five hours and forty-two minutes. 00:28 to 06:10 UTC.
The fix is one word:
restart_policy:
# ANY, not on-failure: the daemon is supposed to run forever, so a
# CLEAN exit is also a failure of intent. on-failure let a SIGTERM'd
# daemon (redeploy, node drain) exit 0 and stay dead for hours --
# scraping silently stopped 2026-08-23 00:28 UTC for exactly this
# reason. Deliberate deploys are handled by stop-first below; this
# only governs unexpected exits.
condition: any
stop-first in update_config still owns intentional rolling deploys. condition: any just makes sure an accidental SIGTERM doesnβt get mistaken for retirement.
The Same Outage, a Second Failure Mode
While the daemon was down, its healthcheck β the thing thatβs supposed to catch exactly this β had its own bug, orthogonal to the exit-code issue. filament-tracker healthcheck --max-age 7200 asks βdid any enabled source succeed in the last 2 hoursβ (ADR-030: health is judged by output, not liveness). Thatβs the right question β until the scrape cadence moved to 6 hours and nobody moved the healthcheck with it.
A daemon sitting idle between two healthy 6-hour cycles looks stale after 2 hours by that check, gets killed by Swarm, reboots, finds nothing due yet, ages past 2 hours again, dies again. A perfectly working daemon, kill-looped by its own healthcheck. max-age went to 25200 (7h β one interval plus slack), with a comment tying the two settings to each other so the next person who changes the cron remembers to change this too.
The Fix That Fixed Nothing
A SIGTERM mid-cycle doesnβt just stop scraping β it abandons whatever pipeline_runs row was tracking that cycle at status = 'running', forever, because the only thing that would ever mark it completed or failed just died. Two dead daemons in one incident left 24 orphaned rows.
The obvious fix: reconcile at startup, since a fresh daemon with replicas: 1 is the only writer that could possibly exist.
async def fail_orphans(self) -> int:
rows = await self._conn.fetch(
"""
UPDATE pipeline_runs
SET status = 'failed',
error_message = 'orphaned: daemon stopped before completing this stage',
completed_at = now()
WHERE status = 'running'
RETURNING run_id
""",
)
return len(rows)
The commit message admits the first draft of this used conn.fetchval() on the same UPDATE β without RETURNING. fetchval on a bare UPDATE returns None no matter how many rows it touched, because thereβs no column to fetch a value from. The reconciliation call would log β0 orphans reconciled,β report success, and leave all 24 rows stuck exactly where they were. A fix for a silent failure, that itself failed silently, in the same commit.
What caught it wasnβt code review β it was a test that checks the actual row count, not whether the function returned without raising:
async def test_startup_reconciles_run_rows_orphaned_by_a_dead_daemon(self, db):
async with db.acquire() as conn:
repo = RunRepository(conn)
orphan = await repo.start(stage="scrape", source="a")
finished = await repo.start(stage="link")
await repo.finish(finished, status="completed", items_in=1, items_out=1)
daemon = Daemon(db, _settings())
assert await daemon._reconcile_orphans() == 1
# ... orphan is 'failed', finished is still 'completed' ...
assert await daemon._reconcile_orphans() == 0 # idempotent
Thatβs three separate outage mechanisms, each one shaped exactly like the last: something reports success β an exit code, a healthcheck age window, a fetchval call β while the actual state of the system diverges from it. The fix for outage mode one and two was a YAML edit. The fix for outage mode three needed a fix for its own fix, and only a test that asserted on data, not on control flow, would have caught the difference.
Trust the Live Stack, Not the Diff
Thereβs a fourth twist, and itβs the one I keep coming back to. Separately from the code fix, this same postmortem found that the deployed Dokploy stack had drifted from what was decided: production was actually running a 2-hour cron while the operatorβs real decision was 6 hours. Config drift, independent of the outage itself.
And when the max-age fix was checked against the live stack afterward β not the git diff, the actual running Dokploy service, read back via its API β the edit hadnβt landed. Still 7200. The commit was real; the deploy that was supposed to carry it hadnβt happened, or hadnβt stuck. The fix was applied again, this time as a direct API patch, and verified with a GET against the live config before anyone called it done.
Every one of these four bugs has the same shape: a system telling you itβs fine when the only way to know is to check the thing itself, not the thing thatβs supposed to represent it. A green exit code isnβt a healthy process. A committed YAML file isnβt a deployed one. A function that returns without raising isnβt a function that did what it claimed. git log describes intent; only the live system describes the truth of whatβs actually running right now β and for a daemon whose whole job is being alive when nobodyβs watching, that gap is the entire failure mode.
β reconstructed from docker-stack.yml diffs, one commit message that names its own bug, and a test that counted rows instead of trusting a return value