The Agents Were Running. The Work Wasn't.
A week of chat history revealed a capture backlog stuck in a nightly timeout loop and morning briefings with nothing behind them. Neither needed a better model.
Part 19 of the series: Building The Hub
In part 10 I wrote that the most useful thing my agents do is something I never ask them to do. This summer I found out the flip side: they can also stop doing it without anyone asking.
Every timer fired. Nightcrawler woke up at 23:00 to process my captures and at 08:00 to write my morning briefing, exactly as scheduled. Nobody was looking at what came out the other end.
The capture backlog had not moved in five nights. The briefings had been written without any Hub context for five weeks.
Nothing was down. It just wasn’t working.
How I found out
Not through an alert. On August 9, I sat down to read a week of conversations in Hub Chat, looking for places where the agents gave weak answers. I expected prompt problems. What I found were two plain software bugs hiding behind fluent output.
Neither of them needed a better model. Both needed the kind of debugging I have been doing my whole career.
Bug one: the backlog that ate itself
The capture loop starts on my phone. Photos, links and notes go in during the day; a nightly processor analyzes them, writes a digest and archives what it handled.
By August the backlog had grown to 100 captures, 98 of them photos. The processor sent all of them to the local model in a single prompt. That request hit its two-minute timeout. Every night.
There was a safety net: if the local model returned something unparseable, the processor escalated to Claude. Sounds reassuring, until you trace it. A timeout is not a parse failure. The escape hatch existed for a different accident.
So each run picked up the same oversized job, failed the same way, and handed the next run the exact same problem, plus whatever I captured that day. A scheduler is very good at turning a failed request into a routine.
The fix changed the unit of work:
- analyze in chunks of twenty and merge the digests
- treat a hard failure as a reason to escalate, not only a bad parse
- once escalated, stay escalated for the rest of the run instead of hammering a struggling local backend
- archive only captures from chunks that were actually analyzed
That last rule deserves its own line. A partially successful run has to preserve the remaining work. Otherwise your backlog shrinks because you lost track of it, which looks great on a dashboard.
Bug two: a briefing with nothing behind it
The empty briefings were sneakier, and if you write JavaScript for a living, you have probably met this one.
In early July Nightcrawler moved to Perceptor, the Mac Mini running Nightcrawler and the local models. The new host had the right HUB_PATH in its .env file. The entry point loaded that file first thing. And still the agents were reading from a directory that only existed on the old machine.
The culprit was a pattern like this, in a shared module:
// shared/hub-reader.js
const HUB_PATH = process.env.HUB_PATH || '/srv/hub';
// heartbeat.js
import { config } from 'dotenv';
import { runSkills } from './shared/skill-runner.js'; // imports hub-reader.js
config(); // too late
ES module imports are hoisted. Every imported module is evaluated before the body of the entry point runs, so hub-reader.js captured its value before config() had loaded anything. It got the fallback, the path of the old host, and kept it for the lifetime of the process.
On the old machine that fallback happened to be correct, which is why nobody noticed. On Perceptor the directory did not exist. Tasks, projects and recent sessions all came back empty, and the briefing politely wrote around the gap.
I reproduced it on the running host: with the correct environment in place, a helper that counts open todos returned null. After the fix it returned 158. The repair was boring, which is the best kind:
// shared/hub-path.js
export const getHubPath = () => process.env.HUB_PATH || '/srv/hub';
Read the environment when you need it, not when the module loads. I applied that everywhere the Hub path was used, added a regression test, and made the briefing skip sections that come back empty instead of presenting them as “nothing today”.
For an agent, missing context deserves the same severity as a broken dependency. A language model will happily write a confident briefing on top of an empty input. That is not a feature.
Wait for the night
Tests passed, the fix was deployed. I did not close the incident.
A unit test can show that a timeout now triggers escalation. It cannot show that the escalation actually chews through a 100-capture backlog on real hardware. So I waited for the real run.
The next morning: five chunks, one escalation to Claude, all 100 captures archived, in about thirteen minutes. The briefing had my tasks and sessions in it again.
Two bugs, one night. For August, done.
Quiet is not the same as fine
The same review surfaced a third problem, the opposite of silence: noise.
The repository scanner reported the same state every morning. Reminders kept raising the same unresolved item. Every message was accurate, and together they trained me to stop reading the channel. That is its own kind of failure.
The scanner now compares against a stored snapshot and reports only what changed: new or closed pull requests, newly stale work, CI going red or green. A weekly full report keeps the big picture. Reminders got memory too: the first one is immediate, follow-ups slow down, and eventually the reminder asks me to decide instead of just repeating itself.
Three questions for every recurring job
I still want quiet agents. In the Mission Control post I wrote that silence is a feature and noise is debt. That still holds. But silence is only a feature when it means “nothing needs you”, not when it means “nothing happened”.
So every recurring job now gets three questions:
- Did it get the inputs it needs?
- Does its failure handling cover the failure that actually happens?
- Does its output prove the work got done?
None of that is AI-specific. It is just operations. Adding a language model did not remove those responsibilities; it only made the failures sound better.
I would love to say the questions solved it. On October 2, I found the same blind spot in the podcast pipeline: 51 failed summaries since mid-September, while every health URL stayed green. An Ollama update had made the local model refuse the JSON output mode the summary step depends on. I switched the model and prepared a retry of the failed tasks. And then, again, I had to wait for a real run before I could call it fixed. The homelab health round now looks at the pipelines themselves, failed workflow runs and whether each model in use still returns JSON, instead of only asking whether the endpoints answer.
Waking up was the easy part. Finishing the work is the job.