The Wrong Model Wrote My Blog Post
4 min read
I caught it in a smoke test. The blog-weekly worker had fired, a draft PR appeared on my site repo, and the text was — fine. Competent. A little flat in places, but publishable if I squinted. What it was not, I realized when I checked the logs, was written by the model I'd told it to use.
The worker calls claude -p on Opus through my Max plan. If Anthropic is genuinely unavailable — network down, API outage — it falls back to a local Ollama model, forge-gemma4-12b, so the task still completes. A reasonable design. The log told me exactly what had happened:
claude -p failed (SessionEnd hook [session-persist.sh] failed: Hook cancelled)
— falling back to local forge-gemma4-12b
Opus had done its job. The response existed. And then the system threw it away.
A teardown hook killed a finished generation
Here is the sequence. claude -p runs, Opus generates the full draft, the CLI begins its shutdown — and fires its SessionEnd hooks. I have two: session-persist.sh and a Cortex lake ingest. Under load, those hooks time out. A timed-out hook is a cancelled hook, and a cancelled hook means the CLI exits non-zero. My run_claude function treats any non-zero exit as failure. So a perfectly good Opus response — already generated, already sitting in the output — was discarded, and the worker dropped to a 12-billion-parameter local model and started over from scratch.
The fallback wrote a draft. The draft was fine. Nobody would have caught it by reading.
The fallback that doesn't announce itself
This is the part that stayed with me. If forge-gemma4-12b had written something obviously worse — garbled syntax, a hallucinated fact, a paragraph that trailed off — I would have caught it immediately. But the local model wrote a passable draft, and passable is exactly the failure mode you don't notice. The system didn't error. It quietly downgraded and kept going, and the only evidence was buried in the worker logs — surfaced because a smoke test prompted me to look, not because anything in the system raised a flag.
Every text-generation worker in the forge — blog-writer, blog-weekly, amygdala, auto-fix, evolution — runs through the same run_generation path. All of them were exposed to the same silent downgrade, every time a SessionEnd hook timed out under contention.
Headless generations don't need teardown hooks
The SessionEnd hooks exist for interactive sessions. A headless one-shot generation has no meaningful session context. It runs a prompt, captures the output, and exits. The hooks were firing on a context that didn't need them, timing out under load, and killing the process they were attached to.
The fix was one argument: run_generation now calls run_claude(..., disable_hooks=True), which passes --settings '{"disableAllHooks": true}' to the CLI. No hooks fire, no teardown timeout, no non-zero exit, no fallback. The agentic callers — autonomous-session, sandbox-builder — still run with hooks enabled, because those are real interactive sessions where the PreToolUse safety hooks matter.
The test that made it obvious
After the fix, run_generation(..., claude_model=OPUS) returned success=True with the model field set to the model I'd asked for and no fallback line in the log. Forty tests passed. The distinction between "the API is down" and "a cleanup hook timed out" is now the difference between a real fallback and a wasted one.
Graceful degradation has a cost nobody prices
Every resilient system has a fallback path, and every fallback path has a trigger condition, and the question you forget to ask is: what else fires that trigger? I had scoped mine to "Anthropic is unavailable." In practice, it fired on "Anthropic is fine but a post-run hook was slow." The blast radius wasn't an error — it was a silent quality downgrade across every worker that generates text, for every run where the hooks happened to be contended.
The draft that came back was fine. That's the sentence that should bother you. A fallback that produces an obvious failure is a fallback you fix. A fallback that produces something passable is a fallback you ship — and the gap between what you asked for and what you got becomes the new normal, and nobody ever notices because nobody ever looks. The log said falling back to local. The draft said nothing at all.