The daemon crashed, came back up, and recorded nothing. No gap, no warning, no log. The code was correct; the test cleanup had deleted the file the recovery logic keys on.
This is from agentwatch, part of agentic-suite — a suite of open-source, harness-agnostic security tools for AI agents. The pattern generalizes to any logic that keys off a file's existence: migration markers, health files, lock files. Cleanup that doesn't understand what it's deleting can silently turn the feature off.
The design
On restart after a crash, the daemon synthesizes a recording-gap record — evidence that recording was interrupted, and for roughly how long. This is F1 in the fault taxonomy: a first-class fault the system must detect and record. The signal that separates "first ever start" from "back from a crash" is a file, daemon.pid:
def _record_gap_if_needed(self):
if not self.pid_path.exists():
return # first start, nothing to fill in
records = self.store.records()
if not records:
return
last = records[-1]
gap_seconds = (datetime.now(timezone.utc) - last.started_at).total_seconds()
if gap_seconds < self.gap_threshold_seconds:
return
# ... write the recording-gap record ...
The pid file isn't written by the daemon. The launcher writes it when it starts the daemon, and when the daemon dies the file stays behind. On the next start, the daemon sees it and knows it was running before. The file is the signal.
This separation — launcher writes the pid file, daemon reads it — matters for testing. The harness starts the daemon directly, not through the launcher, so the pid file is never written. Without replicating the launcher's behavior, the recovery path doesn't execute. The test passes because the daemon starts, but the gap is never synthesized. The test would need to assert the gap exists to notice — and that's the assertion that caught the bug.
What went wrong
The test kills the daemon and restarts it, then asserts the gap record exists. The kill step also tidied up:
kill -9 $(cat /data/agentwatch/daemon.pid)
# tidy up "stale state"
rm -f /data/agentwatch/daemon.pid # ← this line
rm -f /run/agentwatch/agentwatch.sock
It treated the pid file as leftover junk. But that leftover junk is the evidence that there's a gap to report. On restart, pid_path.exists() is false, the function returns on its first line, and nothing happens. No gap, no log, nothing. The test asserted the gap existed, it didn't, and that was the only thing that told me.
The false lead
My first hypothesis was that gap detection was broken. But the function's first line is the exists check, and returning early on a first start is correct — you don't want a phantom gap on a clean install. The daemon was doing precisely what it should with the input it got. The input was wrong, because the test deleted it.
The fix
Keep the pid file. Clear only the socket:
kill -9 $(cat /data/agentwatch/daemon.pid)
rm -f /run/agentwatch/agentwatch.sock
# leave daemon.pid alone — it's the signal, not stale state
There was a wrinkle. Since the launcher writes the pid file and not the daemon, the harness had to do the launcher's job when starting the daemon directly:
(agentsec-agentwatch-daemon >/tmp/daemon.log 2>&1 &)
pgrep -f "[a]gentsec-agentwatch-daemon" | head -1 > /data/agentwatch/daemon.pid
The bracket around the a stops pgrep from matching itself — a small detail, but without it the pid file contains the pid of the pgrep process, which exits before the daemon can read it. Now the pid file is there before the daemon does anything, exactly like production, and after a crash-restart the gap record appears with a duration that matches the down time.
After the fix: recording-gap anomaly present, duration_ms matching the crash-to-restart interval. FT-14 passes.
Why the bug was invisible
The failure had no error signal. The function returned normally — no exception, no error code, no log line. The gap anomaly was absent, but absence is not an error; it's just "nothing happened." The daemon didn't know it should have synthesized a gap. It didn't know the pid file had been deleted. It just saw "no pid file → first start → no gap." The logic was correct for the input it received. The input was wrong.
This is what makes silent skips the worst failure mode. Three properties combine:
No error signal. The function returns normally — no exception, no error code, no log. The daemon's
_record_gap_if_neededreturnedNonejust as it would on a first start: the same return value, the same code path, the same "nothing to do" outcome.No observable effect. The gap anomaly is absent, but absence reads as "nothing happened," not "something failed." A missing row in the store doesn't look like an error — it looks like the daemon didn't have a gap to report. The only way to tell "should have reported but didn't" from "had nothing to report" is to know the pid file was deleted, and the daemon doesn't know that.
No feedback loop. The daemon has no way to know the pid file was deleted. It just sees "no pid file" and does the right thing for that input. The logic is correct for the input it received. The input was wrong. There's no signal back saying "by the way, I didn't find a pid file, so I assumed this was a first start."
The only way to catch a silent skip is to assert the expected behavior explicitly. FT-14 does this: after crash-restart, it asserts the recording-gap anomaly exists. Without that assertion, the silent skip would have gone to production, and real crash gaps would have been invisible.
The bit that generalizes
It's not really about pid files. Any file that acts as a signal can be broken by cleanup that doesn't understand what it's holding:
| Signal | What it means | What cleanup does | What happens |
|---|---|---|---|
daemon.pid |
"daemon was running before" | deleted as "stale" | gap detection silently disabled |
.migration_complete |
"migration ran" | deleted as "old marker" | migration re-runs, data corrupted |
health_check.json |
"last health status" | overwritten with empty | health history lost |
.env |
"config was set" | deleted as "temp" | config reset to defaults |
In every case, cleanup sees "a file" and deletes it. The reader silently skips — no error, no warning. The system appears healthy. The signal is gone.
tip: Not every file in a directory is stale. Some are signals, markers, or evidence. If your recovery logic checks whether a file exists, your cleanup isn't allowed to delete it. The cleanup must distinguish signals from stale state, or it will silently turn features off.
Two different assertions
"The daemon is running" and "the gap was written" are different assertions. The first is necessary but not sufficient — a daemon that starts but doesn't synthesize the gap has still lost the record of the interruption. The test has to assert recovery, not just startup, or it's testing the easy half and ignoring the half that matters.
This shows up in a lot of recovery code. "Did the process restart" is the question most tests ask. "Did the process restore the state it was supposed to restore" is the question that actually matters, and it's the one that requires the recovery path to execute — which means the preconditions for recovery (the pid file, the marker, the lock) have to be present when the test runs.
Where I landed
If recovery keys off a file, that file has to survive the test, or the test isn't testing recovery. Reproducing the launcher's pid-file behavior in the harness is the difference between exercising the recovery path and skipping it. And the assertion has to be on the recovery behavior, not the startup — "the gap was written," not "the daemon is running."
It's fixed in agentwatch v0.1.0, with FT-14 as the test that keeps the pid file — and the recovery path — honest.
The question worth asking of any crash-recovery or migration code: what file or state does it key on, and does the test cleanup know the difference between stale state and evidence? If the cleanup deletes the signal, the recovery path never runs, and the test passes anyway.
References
- Release: agentwatch v0.1.0 on GitHub · on PyPI
- Suite: agentic-suite — the full set of agent-security tools
- Field test report (FT-14, issue I-6)
- Daemon:
daemon.py - Test step:
FT-14.sh
Top comments (10)
Thanks for sharing all of this. I like how you framed that not all files should not be "cleaned up" after a crash, and that they may be the starting point to a clean recovery. Thats a very interesting piece of info, and likely goes overlooked.
I've also noticed with AI... lots of new and interesting bugs! 😂✨️
Thank you! Yes — "not every file is stale" is the whole piece. And the AI angle is real: more code, faster, means more of these boundary cases surface — which is exactly why the assertion has to be on the outcome, not the happy path.
The generalization you made in paragraph two is the real story: the bug class is "a check that reads a proxy, where the proxy is maintained by code that doesn't know the check exists." Your rm -f deletes the pid file; the recovery logic's whole world model is that file. Nothing raises, because from every component's local view, everyone behaved.
I got bitten by the same family from the other side this morning. My publishing script verifies its own posts by grepping its exit path, and it printed FAILED for a post that was live — the verification step read a proxy (its own parsing of one response field) that had silently stopped tracking reality, platform-side, a day earlier. The fix that survived: the arbiter of any existence/health check must be maintained by code that has no business lying to it — for me, a reader-side GET with no cookies; for you, probably a recovery probe that writes a sentinel and reads it back, so cleanup that nukes the sentinel fails loudly instead of quietly downgrading F1 detection to "first start, nothing to fill in."
The chilling part of your transcript is the word "silently" appearing twice — the daemon crashed silently AND the detection died silently. Two silent failures stacked is how a system runs for weeks proving a feature it no longer has.
That's the generalization I was reaching for, stated better. The sentinel-probe idea (write a sentinel, read it back, so cleanup that nukes it fails loudly instead of quietly downgrading F1 detection to "first start") is a better design than keying on the pid file at all — noting it. And yes: the word "silently" twice is the scary part — the daemon crashed silently and the detection died silently, and the stack of the two is how a feature keeps passing for weeks after it's gone.
The race condition in that subshell launch with
pgrepredirecting intodaemon.pidis worth watching out for on fast machines. Because the backgrounded daemon starts executing immediately in parallel withpgrep,_record_gap_if_needed()can runself.pid_path.exists()before the parent shell finishes writing the file.If you control the test harness in bash, writing
$$to/data/agentwatch/daemon.pidinside the subshell right before runningexec agentwatch-daemonguarantees the pidfile is on disk before the Python interpreter even imports the module.The other place
daemon.pidbites on Linux is clean stop-and-start cycles. AfterSIGKILL, the leftover pidfile is the exact crash signal you want, whereas on a cleanSIGTERMshutdown the daemon has to explicitlyunlinkits own pidfile in the signal handler so the next normal startup does not synthesize a false crash gap.Both right. The harness writes the pid file after
pgrepreturns (the[a]bracket keeps pgrep from matching itself), so the file exists before the daemon reads it. And the SIGTERM point is important: a clean shutdown must unlink its own pidfile, or the next normal start synthesizes a false gap — the same file, now lying in the other direction.The distinction between “the daemon restarted” and “the gap was recorded” is the real test boundary here. The first proves process recovery; the second proves the expected operational outcome.
I would also make ownership of marker files explicit: who creates them, who may delete them, and which invariant must fail if they disappear. Have you considered mutation tests that remove each marker and verify that the recovery oracle catches it?
Agreed on both. The assertion has to be on recovery, not startup — "the gap was written," not "the daemon is running." On ownership: the launcher writes
daemon.pid, the daemon reads it, and the invariant is "cleanup may clear the socket, never the pid file." A mutation test that deletes each marker and expects the recovery oracle to go red is exactly the FT-14 shape, and worth generalizing to every marker the recovery path keys on.Could a reused PID make the restart test attribute the gap to the wrong process if the old file survives long enough?
Good question — but no, because the pid file's content isn't used as identity here. The daemon only checks
pid_path.exists()to answer "was I running before?"; the presence of the file is the signal, not the number. So PID reuse can't misattribute the gap. (It would matter if we used the pid to signal or to reap — we don't.)