I asked for fresh intel this morning. The system told me the last gather ran on April 19th.
Today is April 24th.
The job is scheduled daily. It's enabled. The last five runs all exited zero. One of them wrote 1,402 items and I had no idea, because those items landed in a directory nothing on my laptop reads anymore.
This is the worst kind of silent failure. Not the kind where your script crashes and you get a red notification. The kind where every log line says "success" and the data goes nowhere. Exit 0 is not success. It's just the absence of a thrown exception.

The symptom looked like an outage. The cause was an env var.
The pipeline is simple. A launchd agent runs scout gather every morning at 8 AM. scout reads its config and writes intel files to disk. My interactive shell reads those files back when I run scout intel <topic> to look at what's fresh.
When I checked this morning, scout query status said Last gather: 2026-04-19T10:50:52Z. Five days stale. My first thought was that the scheduler daemon had crashed.
It hadn't. scheduler logs 33 showed clean runs every morning. [2026-04-23T08:00:15Z → 2026-04-23T08:03:16Z] exit 0. [2026-04-22T08:00:15Z → ...] exit 0. All the way back.
My second thought was that the gather itself was returning zero items — maybe all the sources were rate-limiting me. I tailed the most recent run log and found this:
[ai-coding/bluesky] incremental filter: 210 -> 134 items
[ai-coding/bluesky] 50 items (capped at 50)
[ai-coding/rss] 1 items -> /Users/joeyhipolito/.scout/intel/ai-coding/2026-04-23T080257_rss.json
[ai-coding/hackernews] 28 items -> /Users/joeyhipolito/.scout/intel/ai-coding/2026-04-23T080257_hackernews.jsonThe gather was working. 1,402 items across 30 topics. Files were being written. Just not where I expected.
The path it wrote to was /Users/joeyhipolito/.scout/intel/.... The path my interactive queries read from was /Users/joeyhipolito/.alluka/scout/intel/.... Two different directories. Same binary. Same user. Same machine.
The difference was one environment variable.
launchd doesn't inherit your shell env
My interactive scout binary reads ALLUKA_HOME from the environment and uses it as the root for everything — config, state, intel files. My .zshrc exports ALLUKA_HOME=/Users/joeyhipolito/.alluka. When I type scout gather in a terminal, ALLUKA_HOME is set, and data lands in ~/.alluka/scout/intel/.
When launchd runs a scheduled job, it runs the command in a completely separate process tree. The env starts empty. Unless the plist explicitly declares EnvironmentVariables, the job gets whatever launchd considers default, which for ALLUKA_HOME is nothing. So scout falls back to its built-in default, which is ~/.scout/. Different directory. Real data. No way to see it from the interactive side.
Here is what the plist said:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/joeyhipolito/.alluka/bin:/Users/joeyhipolito/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>PATH is set so the daemon can find the binary. That's the only env var anyone bothered to declare. ALLUKA_HOME was implied by my shell setup and nobody wrote it down anywhere the daemon could see.
The fix was one stanza:
<key>ALLUKA_HOME</key>
<string>/Users/joeyhipolito/.alluka</string>Five days of data had been writing to the wrong place the entire time this bug was live.

Why exit 0 is the worst failure mode
A crash is easy to debug. Something explodes, you get a stack trace, you find the line that blew up. A job that exits 0 and does nothing useful is harder. There is no stack trace. There is no failure. From every monitoring surface I have — scheduler logs, process exit codes, job history — the run looked fine.
The only signal I had that something was wrong was the absence of downstream effects. The article I wanted to write this week needed fresh scout data and the data was stale. If I hadn't cared about writing the article, I might have gone weeks before noticing. Months if I'd been less nosy.
This is what Tsumugi called "observability theater" in a Bluesky post from last week — the surface metrics all look healthy, and the real failure lives in unmeasured condition alignments. The scheduler's health surface measured whether the job exited. It didn't measure whether the job did what I wanted. Those are different questions.
The general shape of this bug: your scheduled process runs in an environment that's subtly different from your interactive environment. The difference is invisible until you try to read what the process wrote. If the process is write-only from your perspective, the bug can live forever.
How to catch this in your own system

Here's the four-check audit I wish I'd run three months ago. None of this takes more than ten minutes.
Check 1: does every scheduled job write somewhere you actually read from?
For each scheduled job, find the file or directory it writes to. Then find the code that reads those files. If the two paths don't match exactly, you have this bug. My scheduler was writing to ~/.scout/ and my CLI was reading from ~/.alluka/scout/ — same name, different prefix. It was obvious in hindsight and invisible without a direct comparison.
Check 2: run the job manually and check the output directory timestamp.
scheduler run daily-scout
# ... wait for completion
ls -lt ~/.alluka/scout/intel/ai-coding/ | head -5If the top file's timestamp isn't within the last few minutes, the job is writing somewhere else. This catches path mismatches immediately. It doesn't catch every env var gap, but it catches the one that bit me.
Check 3: diff the job's env against your interactive env.
# What the daemon sees:
ps eww -p $(cat ~/.alluka/scheduler/daemon.pid) | tr ' ' '\n' | grep -E "^[A-Z_]+="
# What your shell sees:
env | grep -E "^(ALLUKA|CLAUDE|SCOUT|OBSIDIAN)"Any env var your interactive code depends on that isn't in the daemon's env is a landmine. Not all of them will explode — some have safe defaults — but you should know which ones exist and decide deliberately.
Check 4: audit launch plists for implicit assumptions.
Open every ~/Library/LaunchAgents/com.*.plist file and look at the EnvironmentVariables dict. If it only has PATH, you're trusting built-in defaults for everything else. That's fine if your tools don't care about env. The moment any tool you're running reads an env var, you need it declared there.
The fix for all four is the same: make the environment explicit. Either declare the env vars in the plist, or inline them in the command itself — ALLUKA_HOME=/Users/joeyhipolito/.alluka scout gather. Belt and braces is fine here. The cost of one redundant export is nothing compared to five days of invisible data loss.
What I changed
I did all four of the above an hour ago and found one more place this bug was hiding — another job had the same implicit env dependency. Fixed both.
Then I merged the five days of data that had been landing in ~/.scout/intel/ into the place my tools actually read. rsync -a --ignore-existing ~/.scout/intel/ ~/.alluka/scout/intel/ moved 1,221 files over without clobbering anything. Item count jumped from 42,214 to 57,127. The scout query status now shows the real last-gather timestamp.
Then I restarted the scheduler daemon and verified the new PID had ALLUKA_HOME in its environment. ps eww confirms it. Next scheduled run at 8 AM tomorrow will write to the right directory.
I lost five days of observed freshness, not five days of data. That was the lucky version of this bug. The unlucky version is the one where the two directories drift long enough that the "stale" side gets garbage-collected and you lose the real history. I came close to that on a different project last year. I won't again.
The question I'm sitting with
The thing that bugs me is: the scheduler daemon is telling me what I asked it to tell me. It runs jobs and reports exit codes. Those are its contract. The contract doesn't include "verify the job actually did the thing the human wanted." That's a much harder thing to instrument.
I could add a post-run check to every job — if intel wasn't written to the expected path in the last N seconds, alert. That's a reasonable band-aid. But the real problem is that "success" and "did the expected thing" are different questions, and most scheduling systems answer only the first one.
I don't have a clean answer for how to close that gap without building a whole second layer of observability around the scheduler. If you've solved this in a system you run, I'd like to know how. I'll probably end up with per-job sentinel checks — a small canary file each job must update, watched by a separate sweep — but that feels like I'm just building the observability layer I'm complaining about.
For now: the daemon reads the env var. The job writes to the right directory. And I'm going to get suspicious a lot faster next time something that should be fresh isn't.