DEV Community

Cover image for My Health Check Watched the Wrong File
Self-Correcting Systems
Self-Correcting Systems

Posted on AI-assisted

My Health Check Watched the Wrong File

Irony of monitoring small Telegram pollers wrong

I wrote a rule for my own agents:

Liveness is not usefulness. Artifact age beats PID.

A job that is loaded, looks healthy to the supervisor, and is producing nothing is the failure worth catching. Anyone can detect exit 127.

Then I applied the rule to my own machine and got the wrong answer.

What I saw

Three listener jobs. launchctl lists them. pgrep -f sentinel_listen.py returns a PID. Their log files:

sentinel_listen.log    8103 bytes   mtime 2026-08-11
courtside_listen.log   8127 bytes   mtime 2026-08-11
eye_listen.log         8149 bytes   mtime 2026-08-11
Enter fullscreen mode Exit fullscreen mode

Last written August 11. By my own rule, that is three dead jobs wearing a costume. I had the sentence written.

A word about what these are, so nobody has to guess. They are small Telegram pollers, 148 to 243 lines each. They wait for a message and answer it. They are not the interesting part of anything I build, and the failure below is not a failure of theirs — it is a failure of how I checked them. I am using them as the specimen precisely because they are simple. If a health check cannot get a 148-line polling loop right, it will not get anything harder right either.

What the script actually does

Before publishing that, I opened the thing that writes the file. There are three print() calls in it:

scripts/sentinel_listen.py:120    print("SENTINEL LISTEN: no rail configured ...")
scripts/sentinel_listen.py:128    print("SENTINEL LISTEN: awake, polling every 20s ...")
scripts/sentinel_listen.py:142    print(f"SENTINEL LISTEN error: {type(exc).__name__}: {exc}")
Enter fullscreen mode Exit fullscreen mode

Startup, banner, error. Courtside and eye are the same shape at :144/:152/:175 and :198/:201/:236.

The successful path prints nothing. There is no "still polling" heartbeat. On the normal path it calls getUpdates with a 25-second long-poll, writes a small state file, sleeps 20, and says nothing at all. So a quiet pass is the poll plus 20 seconds, not 20. A caught exception sleeps 30 and then takes that same sleep on the way round. The interval is not uniform across passes.

So silence in that log does not mean the job stopped. The file is only written on startup and on a caught exception, and I was reading its silence as death.

I cannot go further than that, and the reason matters. These three run without -u, so stdout is block-buffered. An exception printed one minute ago could still be sitting in a buffer instead of on disk. An unchanged file does not prove no errors occurred. It proves the file has not been modified. Those are different statements, and the second one is the only one I can support.

The 8103 bytes are an old error storm — one banner and eighty caught exceptions: 76 URLError, 3 timeout, 1 HTTPError. That is what the file records. What it has failed to record since is a question the file cannot answer about itself.

The file that was actually moving

While I was checking whether these jobs were dead, they were writing. I watched all three state files advance. Sentinel, for example:

$ stat -f '%Sm %N' -t '%H:%M:%S' scripts/sentinel_listen_state.json
  11:13:03   sentinel_listen_state.json
$ stat -f '%Sm %N' -t '%H:%M:%S' scripts/sentinel_listen_state.json
  11:15:19   sentinel_listen_state.json
Enter fullscreen mode Exit fullscreen mode

The log had not been touched since August 11 and the state file was seconds old. Both belong to the same listener job, but only one of them was designed to move on ordinary loop iterations — and they live in different directories. I was watching agent_outputs/. The living file was in scripts/.

I picked the artifact that only moves on startup and on failure. The last startup line in that file is from August 11. The process itself came back on September 18, and at the September 22 check, the banner emitted by that restart had not reached the log file on disk.

The better signal is also not proof

Here is where I have to stop myself a second time, because "check the state file instead" is the obvious lesson and it is not quite right either.

This is the end of courtside's loop:

while True:                                           # :153
    try:                                              # :154
        for upd in updates.get("result", []):         # :156  (body elided)
            ...
            STATE_PATH.write_text(json.dumps(state))  # :173  only after a handled message
    except Exception as exc:                          # :174
        print(f"COURTSIDE LISTEN error: {type(exc).__name__}: {exc}")
        time.sleep(30)
    STATE_PATH.write_text(json.dumps(state))          # :177  every pass, either way
    time.sleep(POLL_SECONDS)                          # :178
Enter fullscreen mode Exit fullscreen mode

There are two writes. The one at :173 happens inside the try, after a message is handled. The one at :177 is at loop level, outside the except, so it runs on every pass — including a pass that just caught an exception and slept.

That second write is the one that keeps courtside's timestamp fresh, and eye is built the same way. So for those two, a fresh state mtime proves the loop is turning. It does not prove a poll succeeded, and it cannot tell a good pass from a failed one.

Sentinel is not the same. It has no loop-level write — only :140, inside the try, after getUpdates returns. So sentinel's timestamp is a stronger signal than the other two. It shows that the API call returned parseable JSON and the loop reached its write. It does not separately record API-level success, whether messages arrived, or whether a reply was delivered. An empty successful poll is normal. Three jobs I had been treating as one row are three different write sites.

And the contents are not one story either. I checked all three:

sentinel    offset 0            history 0      13 bytes
courtside   offset 51848716     history 4     545 bytes
eye         offset 63652330     history 8    2988 bytes
Enter fullscreen mode Exit fullscreen mode

I had looked at sentinel, seen {"offset": 0}, and was about to write that all three had never consumed a message. Two of them hold a non-zero saved cursor and a non-empty history. Three jobs that look identical from launchctl are in three different states, and the only reason I know that is that I opened all three instead of one.

Even sentinel's zero is smaller than it looks. It is the saved cursor right now. It is not a history of everything that job has ever done.

What each thing actually establishes

Observation Supports Does not establish
pgrep returns a PID a process exists at this moment that it polls or delivers
log file unchanged the file was not modified that no exception occurred
state mtime advances — courtside, eye the loop reached its unconditional write at :177 that a poll succeeded; it writes after a caught error too
state mtime advances — sentinel getUpdates returned and parsed, reaching :140 API-level success, that anything arrived, or delivery
saved offset / history the file currently holds those values lifetime behavior, or delivery
a successful-poll receipt — I do not emit one
a delivered-result receipt — I do not emit one

The last two rows are the point. Every signal I have is incidental — something the program happens to leave behind while doing its job. None of them was written to record that the job succeeded. Those are the two I would have to build, and until they exist I am inferring operation from debris.

My rule was an improvement on checking the PID. It was not the end of the ladder. "Artifact age beats PID" needs a harder second question: which operation am I checking, what evidence does that operation emit, and under what conditions does absence of that evidence mean failure?

A newer file is not automatically a better signal. You have to know what causes it to change. A quiet listener with no incoming work needs a different expectation than a queue with messages waiting, and a timestamp cannot tell those apart on its own.

The buffering part, which is real and separate

There is a second thing going on with those files, and it is worth separating from the write semantics. Two properties compound. The successful path barely writes to stdout at all, so the stream produces very little. And because stdout is block-buffered, the few lines it does produce may not reach disk promptly. Either one alone would be survivable. Together they make log silence ambiguous rather than informative.

I had a live example of the second half. These processes restarted on 2026-09-18 and the startup path prints a banner at :128. On September 22 the file still contained exactly one banner, with an mtime of August 11. The September restart ran the print, and the line was nowhere on disk. Either it was waiting in a buffer or it was gone. The result at the end says which.

These three plists run /usr/bin/python3 script.py. Four sibling jobs on the same machine run /usr/bin/python3 -u script.py. When Python's stdout is non-interactive, as it is when these jobs write to a file, it is normally block-buffered. The exact size is a runtime detail rather than a guarantee: Python commonly uses the file's block size and falls back to io.DEFAULT_BUFFER_SIZE, which on this interpreter reports 8192. -u and PYTHONUNBUFFERED=1 disable that buffering for stdout and stderr.

finance    -u   2026-09-22 19:50   52479 bytes
health     -u   2026-09-22 09:22   55759 bytes
kairos     -u   2026-09-22 09:30   55029 bytes
organizer  -u   2026-09-22 09:21   61883 bytes

sentinel   no   2026-08-11 16:52    8103 bytes
courtside  no   2026-08-11 16:52    8127 bytes
eye        no   2026-08-11 16:57    8149 bytes
Enter fullscreen mode Exit fullscreen mode

On September 22, four out of four with the flag had logs that moved that day. Three out of three without it were frozen. That is a seven-job cohort on one machine, not a law of nature, and I am reporting it as what it is.

Both things are true at once: those programs have almost nothing to say, and without -u, the little they do say is not guaranteed to reach disk promptly.

A prediction, dated before I know

Frozen 2026-09-17. The ledger entry also carried my buffering explanation and an ESTABLISHED socket condition; the socket clause was removed by an amendment on 2026-09-17 and the script-name test added on 2026-09-21. This is a summary of the observable part that remains:

Those three logs will still carry mtime 2026-08-11 on 2026-10-01, while the processes are still alive.

Wrong on 2026-10-01 if any of the three logs has an mtime on or after 2026-09-18 while its plist still lacks -u and PYTHONUNBUFFERED, or if any of the three listeners is absent when tested by script name on that date. An intermediate restart is not a falsifier; the prediction is about what is true on the resolution date.

Not by PID. My ledger recorded 86150 / 86156 / 86157. The machine rebooted on 2026-09-18 and they came back as 859 / 860 / 853. On September 21, ps -p 86150,86156,86157 returned no rows. Resolving against those numbers would have written WRONG against a prediction that was still holding at the time.

That is an observation, and it is the only part I am committing to. The buffering account above is my explanation for it, and a correct prediction would not prove the explanation. If the October 1 check fails, I will say so here.

I am not adding -u until then. Changing the launch configuration now would invalidate the observation window I froze for the prediction.

October 1: it failed

By September 25, the logs had changed.

              frozen               Sep 29 snapshot (mtime Sep 25)   Oct 3 reading
sentinel   8103 B  2026-08-11    16276 B  2026-09-25 06:32    65095 B
courtside  8127 B  2026-08-11    16315 B  2026-09-25 20:39    65322 B
eye        8149 B  2026-08-11    16333 B  2026-09-25 21:27    57130 B
Enter fullscreen mode Exit fullscreen mode

The plists still run /usr/bin/python3 script.py, no -u, no PYTHONUNBUFFERED, and their modification times are July 5 and July 11, before the prediction was made. So the launch configuration did not change across the window, and the September 25 log changes satisfy the frozen falsifier retrospectively, even though I failed to run the resolver on October 1. That first falsifier alone makes this WRONG. The process condition was never observed on October 1 itself. Checked later, on October 2 and again on October 3, all three were alive by script name, the same PIDs that started 2026-09-18 06:28:01. Eye's last change was October 2 at 20:20; its Oct 3 number is a reading, not a third move.

The new content is what the write sites said to expect: caught-error lines. As of October 3, sentinel's log holds 673 error lines. 594 are URLError, and 459 of those are this one:

SENTINEL LISTEN error: URLError: <urlopen error [Errno 8] nodename nor servname provided, or not known>
Enter fullscreen mode Exit fullscreen mode

That is a name-resolution failure. The error alone does not tell me whether the name was invalid or whether resolving a valid name failed. Between the frozen sizes and the September 29 snapshot, the files had grown by 8,173, 8,188 and 8,184 bytes. Together with the caught errors and the buffered configuration, those increases are consistent with accumulated output being flushed. I did not trace the writes, so I cannot identify the exact flush count or trigger from these snapshots, and a consistent result does not prove the explanation.

The bet was the part that lost. I bet the logs would sit still until October 1. Enough new error output accumulated that the logs had moved before October 1.

The banner question has an answer too. The sentinel log now has two startup banners. The second is line 82, directly after the 81 lines that were there on August 11, and the only startup these processes have had since August 11 was September 18. That banner is the September 18 startup line. It was not lost. It eventually reached disk. That is consistent with the buffering explanation above. I had a second dated prediction that said the opposite, that the restart's output was destroyed and the file would still be 8103 bytes with one banner on October 5. As of October 3 the file is 65,095 bytes with two banners. It is already not that file, so Monday can only confirm a result that is already visible, unless the file shrinks. Under my own rule it stays pending until then.

One more thing the date did not do. Nobody ran the check on October 1. The resolver is a script. An interim run on September 29 had already reported failing criteria, but no run was recorded on October 1, and the first recorded run after the deadline was October 2 at 23:35 UTC. Writing the falsifier down before the result stops me from moving it afterward. It does not make anyone show up on the day. If you bet on something, put the check on a scheduler, not on your memory.

What to run on yours

# 1. does the process exist
pgrep -fl your_job.py

# 2. what does the log say, and WHEN does your program write to it
stat -f '%Sm %z %N' -t '%Y-%m-%d' /path/to/your_job.log
grep -nE 'print\(|log\.|logger\.' /path/to/your_job.py

# 3. -u as its own argument, not a substring match
plutil -extract ProgramArguments json -o - ~/Library/LaunchAgents/com.you.yourjob.plist

# 4. if you made a dated bet about it, schedule the check
#    (a falsifier nobody runs on the day is a note, not a test)
Enter fullscreen mode Exit fullscreen mode

These are macOS commands. On Linux, stat -c '%y %s %n' and your service manager's unit file do the same jobs.

Step 2 is the one I skipped. Read the write sites before you interpret the file. If the only print() is inside an except block, an old timestamp is not evidence of failure. It is also not evidence of health. It is evidence the file did not change.

What this does not establish

It does not prove these three jobs are healthy. On September 22, state mtimes showed courtside and eye reaching their loop-level write, sentinel getting a parseable response from getUpdates, and two of them holding a non-zero cursor. The October error logs do not re-prove that. What still holds is that I do not have a signal tied to a poll actually succeeding. That is the thing I have to go build.

It does not prove every quiet daemon is fine. It proves that for these three, on this machine, the file I chose could not answer the question I was asking it.

It does not prove -u is the only fix. PYTHONUNBUFFERED=1 would do the same thing.

And it does not prove the buffering story. The October result fits it, including three aggregate growth deltas clustered around 8 KiB, but fitting is not proof. The prediction tested the observation, and the observation was wrong.

The rule needed replacing, not defending. "Artifact age beats PID" got me off the PID and onto a file with no per-poll success record in it. The version I can actually use is longer and worse as a slogan: name the operation, name the evidence it emits, and name the conditions under which its absence is a failure. If I cannot write that third part down, I do not have a health check. I have a file I like looking at.

Top comments (20)

Collapse
 
hayrullahkar profile image
Hayrullah Kar •

The detail I keep coming back to is that none of the three print() calls carry a timestamp. So even after the buffered output reached disk, the log can tell you what failed and how many times, but not when. The 459 name-resolution errors in sentinel's log could be one bad hour on September 25 or a steady drip since the September 18 restart, and the October reading looks the same either way. The one file that records failures can't answer how often they happen.

Putting the time on the line, print(f"{datetime.now().isoformat()} SENTINEL LISTEN error: ...") or logging with %(asctime)s, turns that log into a rate. Count error lines per hour against the passes you'd expect per hour from the sleep intervals, and a ratio near one says every pass is failing, which no mtime can tell you. With the prediction resolved, adding -u and the timestamp together no longer costs you the observation window.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems •

You're right and it's worse than it looks. The 459 errors could be one bad hour or a steady drip and the file can't tell me which. A timestamp on the line turns it into a rate. Both predictions on those logs are resolved now, so there's no observation window left to protect. -u and the timestamp should go in together.

Collapse
 
hayrullahkar profile image
Hayrullah Kar •

Makes sense to add them together now that nothing is left to protect. The first day of timestamped lines should settle the one bad hour versus steady drip question on its own.

Thread Thread
 
kenielzep97 profile image
Self-Correcting Systems •

agreed. full honesty i checked this morning and hadnt made the change yet. the sentinel listener is still running without -u and none of its log lines have timestamps. so i cant answer the bad hour versus steady drip question yet

Thread Thread
 
hayrullahkar profile image
Hayrullah Kar •

Thanks for checking instead of guessing, that is the useful kind of answer. Nothing is lost by the delay, since the question only gets answered once timestamped lines exist anyway. Whenever the -u flag and timestamps go in, the first day of logs will tell the story.

Collapse
 
reidmarlow profile image
Reid Marlow •

The loop-level write in courtside and eye is the exact pattern that turns a health check into a false green. Writing state.json outside the try/except block means the watcher verifies the supervisor kept the thread spinning, but never whether the underlying socket actually drained data or timed out.

A pattern that saved me on similar quiet pollers is splitting the heartbeat into two distinct fields: loop_tick_ts and work_completed_ts. If loop_tick moves while work_completed stalls past the expected polling interval plus backoff, you get a clean alert on thread starvation without having to parse buffered stdout logs.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems •

Splitting it into loop_tick and work_completed is cleaner than what I had. Right now the only thing separating them is which line the write sits on, and you only find that out by reading the code. With two named fields a stall is just loop_tick moving while work_completed doesn't.

Collapse
 
launchgatecheck profile image
Launch Gate •

I'd keep successful empty polls separate from handled-message and delivered-reply receipts. A quiet listener can be doing its job with no work to complete, while a fresh cursor can coexist with a failed send. A paired fixture could return an empty successful poll, then a message whose reply fails: the first should keep poll health green without inventing delivery, and the second should show the delivery failure even if loop and poll timestamps advance. Does the planned receipt distinguish API-level success from parseable JSON as well?

Collapse
 
kenielzep97 profile image
Self-Correcting Systems •

Not in the listeners. Sentinel parses the response and never looks at Telegram's ok field. Another agent I run does check ok before it counts a send. Nothing separates an empty successful poll from a delivered reply yet, so your paired fixture is exactly the shape that receipt needs.

Collapse
 
panthpatel profile image
Panth Patel •

Your courtside write at :177 is the same gap a reader found in my heartbeat. In my ticket tracker the orchestrator beats every 60 seconds while an agent works, and the board turns red after 75 seconds without one. The beat comes from the loop around the agent, so a hung agent keeps beating with a frozen message. A fresh timestamp proved the wrapper was turning, not that work moved. Sentinel's write after getUpdates is the shape I'd copy.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems •

Same shape exactly. The beat comes from whatever wraps the work, so a frozen worker inside it still looks alive. For a poller I'd want the beat tied to a successful poll, even an empty one, and kept separate from "a message was handled," so a quiet day doesn't look like a failure.

Collapse
 
panthpatel profile image
Panth Patel •

The quiet day is the part I'd have got wrong. On my board only "working" can go stale; idle, done and error stay true until the next beat, so silence while idle never reads red. For the poll beat, how much age would you allow before alerting, given getUpdates can sit through its whole long-poll timeout?

Thread Thread
 
kenielzep97 profile image
Self-Correcting Systems •

honest answer we havent built the poll beat yet. on our sentinel listener a normal empty pass is about 45s, telegram holds getUpdates up to 25s and the loop sleeps 20s. a stuck request caps at 40s, so about a minute with the sleep. anything past a minute is already off. id start testing an alert around 2 to 3 minutes but thats my pick, nothing has proven it yet. and the beat should come from a finished poll, empty ones included, so quiet never reads as dead

Collapse
 
innokentyb profile image
Kent Bodrov •

This is a great example of a green check proving only that its proxy is alive. I like to add one acceptance step whenever a health signal is introduced: break the exact artifact or dependency the check claims to observe and verify that the signal turns red. That small mutation test catches path drift, stale aliases, and “healthy wrong thing” failures before the dashboard becomes trusted evidence.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems •

That's the step this post was missing. The version that would have caught mine is breaking the thing the signal is supposed to stand for, not just the file it reads. Cut the listener off from Telegram and see if the check notices.

Collapse
 
praveen_pasco profile image
Praveen Pasco •

One thing that shows up once the check starts reading the state files instead of just their mtime: Path.write_text opens the file in "w" mode, which truncates it, and then writes. A reader that lands between those two steps gets an empty file, and json.loads blows up. Your check would then report "state unreadable" for a listener that's fine. The mtime has already moved by then, too.

It's a tiny window with files this small, but checks run forever. Writing to a temp file in the same directory and then os.replace() onto STATE_PATH closes it, since the rename is atomic on the same filesystem. Then whatever the checker reads is either the old state or the new one.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems •

Good catch. The listeners still use write_text, so a checker can land in that gap and call a healthy listener unreadable. I used the pattern you're describing in another tool this week, temp file in the same directory then os.replace. The listeners don't have it yet.

Collapse
 
hershys profile image
Hershys •

The thread is about checks that go green when they shouldn't. There's a cousin: a watcher that runs fine and still tells you the wrong thing, because the bug is in how it stores its own state, not in the check itself.

I hit both building a small folder watcher (stdlib only). It snapshots a folder into numbered dirs and diffs consecutive snapshots. Two bugs, both in the store:

  • I sorted the snapshot dirs as text, so __1000 sorted before __999. Invisible until the count passes 999, then the diff misorders and reports the wrong thing with full confidence. Fix: sort by (date, int(index)), not the raw name.
  • int(name.split('__')[1]) on every entry. One stray file in the store dir killed the whole run. A watcher that dies on a foreign file fails exactly when the folder gets messy.

Lesson I took: test the check and its store together. A perfect comparison over an ambiguous store is a confident lie.

One more, on signal rather than correctness: mine prints removals before additions. If "gone" is buried under "new", the report is a false green in disguise.

Free, complete, stdlib only, if it is useful: hershys.mataroa.blog/blog/a-tiny-s...

Collapse
 
kenielzep97 profile image
Self-Correcting Systems •

the __1000 before __999 one is nasty because it only shows up once its been running long enough for you to trust it. test the check and its store together is going in my notes. ours was the same shape, the check was fine and what it was pointed at was the problem

Collapse
 
shieldxbot profile image
shieldx •

User Safety: safe