The tick was green: ✓ forecast regenerated. The forecast's date hadn't moved.
One of my scripts printed it on 4 September. Since 13:48 that afternoon, every AI job my app runs in the background had failed: the morning brief, the summaries, the weekly review, the forecast and the rest. Every one of them reported success.
Two things had gone wrong at once. One made the jobs fail. The other made each failure look like a success, and that's the one I'd look for in your code first.
How one chat changed what runs in the background
My command center runs its AI through the Claude Code CLI, on the same login as my own chat sessions. Earlier that day I had switched models in one of those chats with /model. The switch is saved to ~/.claude/settings.json, so it outlives the chat and becomes the default for every session that names no model of its own.
My background jobs named no model, so they inherited it. The installed CLI, version 2.1.198, refused it with a 400: version 2.1.251 or newer is required.
- 1A model switch in one chat
- 2Saved as the default
- 3Background jobs inherit it
- 4The CLI refuses it
- 5Each job says ok
Eight failure paths that returned ok
Each job caught its error. Then it returned the error inside an object that said the opposite:
// Before: the failure path of eight AI jobs
return { ok: false, reason, ...(await syncAndSpine()) };
// After: the failure is written last, so it wins
return { ...(await syncAndSpine()), ok: false, reason };The sync had worked, so it returned its own ok: true. In a JavaScript object literal the last key wins, and the spread came last. Eight failure paths did this: the brief, the summaries, the weekly review, the company reads, the forecast, the journal analysis and two for the milestone suggestions.
The usage ledger missed it too. The failure path returned before the logger wrote its row, so the ledger held only successes and read as if nothing had broken.
Moving ok: false after the spread surfaced the real error at once: the model setting. Removing it was a one-line change to that settings file, which the AI session fixing the bug wasn't allowed to make. The guard was right. What my background AI runs on is my call.
Three times in five days, so the code handles it
On 9 September it happened twice more. Each time, a model I had picked for one conversation became the model for everything in the background. So it stopped being something I have to remember and became something the code handles:
- It retries once, and only when the CLI's own error says the model was rejected. The retry runs on
sonnet, an alias the CLI resolves to a model it supports. Any other failure passes straight through, so a real error can't hide inside a retry loop. - The retry reports itself. Its note travels up to the job's report and names the setting to remove, instead of being swallowed.
- The script that printed that tick, my digest script, now checks the model setting before it imports anything. A broken setting is a warning before anything changes, where it used to surface at the last step, after my files had changed.
- Both attempts are metered on one row, because a rejected call can still be billed.
I tested it the direct way: I pinned a model the CLI rejects and watched the forecast recover on its own. The retry can't repeat a side effect, because there are none to repeat: my background runs are read-only, and the app writes their output only after a success.
Check the last real success, not the tick
Now I judge a job by the thing it makes. My forecast carries the date it was written, and the numbers it reads carry theirs. When the forecast falls behind its numbers, the job is broken, whatever it reports.
My app also shows a "last full sweep" time, and it moves only when a sweep ends with zero failures. On a quiet day, nothing regenerating is usually correct, and that one timestamp is how I tell quiet from broken.
Eight weeks later an audit found the same shape one level up. My harness's health score said 100 out of 100 all month, while 32% of model calls failed and three mornings had no brief. The score checked that every part was in place. Nothing checked what the parts produced.
Today, find the line where each of your background jobs sets ok. If anything is spread in after it, that job can lie to you too.
That check and 24 more are in my runbook for jobs that run while nobody watches, each with the story behind it.
Free · one emailThe Unattended AI Runbook25 checks for AI jobs that run on their own, in five lists: before it runs, while it runs, when it fails, when it writes, and the proof it worked.Get the runbook


