DIL.DEV
LOADING0%
DIL.DEV
← ALL NOTES

The Idle Watchdog That Killed Healthy Reasoning Models at 300 Seconds

A file-size watchdog could not tell a thinking model from a wedged process. A throttled heartbeat sidecar fixed it without letting genuine wedges survive.

shellai-toolingdebuggingstreaming

A terminal showing a shell script diff with heartbeat and watchdog logic side by side.

A DeepSeek doer model died at exactly 300 seconds on every attempt. Not occasionally. Every time. The number 300 is MAX_IDLE_SECONDS in the watchdog, which made the cause obvious in hindsight, but the path to the fix involved two separate bugs and a third issue hiding under a shell flag.

What the Watchdog Actually Measured

The idle watchdog in scripts/dappa.sh works by polling a capture file and checking whether it has grown since the last poll. No growth means no activity. Enough idle polls in a row and the process gets terminated.

That logic is perfectly reasonable for a process that should be producing output continuously. It falls apart the moment you introduce a model that thinks before it speaks.

  • Reasoning models are quiet by design. They spend the bulk of their wall-clock time on internal chain-of-thought before emitting a single token of final output.
  • The capture file did not grow during thinking. The stream filter was written to write output only at the final result event, not on intermediate stream events.
  • From the watchdog's perspective, silence equals wedge. Five minutes of genuine reasoning looked byte-for-byte identical to a process that had locked up.

The watchdog was correct by its own logic. The logic was just missing a signal.

The Heartbeat Sidecar

The fix adds a .hb heartbeat sidecar file alongside the capture file. The stream filter appends to it on every parsed stream event, throttled to at most once per second to avoid hammering the filesystem.

The watchdog now measures activity via an _activity_size function that checks both files:

  • Capture file growth still counts, same as before.
  • Heartbeat file growth also counts, covering the thinking phase.

A model that is genuinely working emits stream events. Those events update the heartbeat. The watchdog sees activity and stays its hand.

A model that is genuinely wedged emits nothing. Neither file grows. The watchdog kills it at MAX_IDLE_SECONDS, exactly as intended.

The fix is additive. The existing kill behaviour for real wedges is unchanged.

The Envelope Event Bug

While chasing the watchdog issue, a second bug surfaced. The stream filter was matching content_block_* delta events. That is the shape of the raw Anthropic API stream.

The CLI invocation uses claude --print --output-format stream-json. That flag causes the CLI to emit envelope events, not raw API deltas. The envelope types are assistant, system, and result.

  • Nothing matched the filter's patterns. Every event passed through without triggering any print logic.
  • Output appeared to work because the process did eventually write the capture file at the final result event, which was itself an envelope event that happened to be handled separately.
  • The symptom was invisible in short runs. You only noticed if you were watching the terminal expecting incremental output and seeing nothing until the very end.

The fix updates the filter to match envelope event types. Both paths were verified with envelope-stream stubs before the commit landed.

The Silent Startup Kill

There was a third issue, quieter than the other two. Crash-recovery logic at startup used grep to look for state in existing files.

Under set -e -o pipefail, a grep that finds no matches exits with code 1. Under set -e, a non-zero exit kills the script immediately. No error message. No indication anything went wrong. Fresh runs with no prior state to recover simply died at startup.

  • The script appeared to do nothing on a clean first run.
  • The watchdog never even started because the script was already dead.
  • The fix guards the grep calls so a no-match result does not propagate as a fatal error.

This one is a classic set -e footgun. Grep's exit codes are meaningful in a way that most commands' are not, and set -e does not distinguish "command failed" from "command found nothing".

Why 300 Seconds Was the Tell

The precision of the failure was the diagnostic gift. If the model had died at random intervals, the cause could have been network timeouts, memory pressure, any number of things. Dying at exactly 300 seconds, every time, pointed directly at a timeout constant.

Once you know it is the watchdog, the next question is why the watchdog fired on a healthy process. That leads you to the capture file. That leads you to when the filter writes. That leads you to the heartbeat fix.

The commit is fe1054e from 2026-07-13, 86 insertions across scripts/dappa.sh.

Verifying the Fix Without a Live Model

Both the heartbeat logic and the envelope event matching were verified with stubs rather than live model calls. Envelope-stream stubs replay a recorded sequence of events through the filter, so you can confirm that heartbeat writes happen on intermediate events and that envelope types print correctly, without waiting for a real model to think.

This matters because reasoning models are slow by nature. A test suite that requires a live model run to verify watchdog behaviour would itself take minutes per case. Stubs make the feedback loop short enough to be useful.

What I'd do differently

  • Write the heartbeat from the start any time a filter is involved in a watchdog loop. The assumption that "output equals activity" only holds if the filter is transparent, and filters rarely stay transparent.
  • Test stream filters against the actual CLI output format, not the raw API shape. The envelope versus delta mismatch would have been caught immediately with a single recorded CLI session replayed through the filter.
  • Audit every grep under set -e at the point of writing it, not later. The startup kill was invisible precisely because set -e makes failures silent when there is no explicit error handler.
  • Log the watchdog's reason for firing before it kills anything. A log line saying "idle: capture=0 bytes growth, heartbeat=0 bytes growth, killing" would have made the original diagnosis a one-minute job instead of a longer one.

NOTES BY EMAIL

Get the next note in your inbox.

One email per note. No spam. Reply “stop” any time and you are off the list.