Your Per-Edit Test Hook Is the Cost You Can't See

← hexisteme · notes · 2026-09-05

A PostToolUse hook reran an entire test file after every single-file edit during a 24-day agent-fleet session. 971 failed runs totaling 5.0 hours showed up in the transcripts, but a passing run produced no output at all, so the true number of times the hook fired was uncountable and a tool-call audit missed it completely. Worse, the hook fired mid multi-file edit, scoring the intentionally incomplete first half of a change as a failure and pushing the model to revert work instead of finishing it. The fix moved verification from a per-edit hook to a queue drained once by a Stop/SubagentStop hook at the end of each turn — same suite, same guarantee, one run instead of hundreds.

I audited a 24-day session on a short-form video pipeline I run with a small fleet of coding agents. On paper the headline numbers were unremarkable for a project that size: 52,188 main-thread turns, roughly 1,065 turns per episode, and 1,873 calls to pytest sitting right there in the Bash tool-call log. Then the person actually using the pipeline flagged something that didn't match any of those numbers at all: the worker, they said, kept trying tests it didn't need to. That complaint didn't trace back to Bash. It traced back to a hook I'd wired in during an earlier session and half-forgotten.

Not another measurement-artifact story

It's worth being precise about what kind of failure this is, because it sits right next to a few others I've written up that look similar from a distance and aren't. A measurement proxy inserted to observe a system can quietly change what that system does, inflating the very number you added it to see. Pooling behavioral metrics across two different roles in a fleet — a long interactive session and a short one-shot worker — without separating them first can turn two nearly-identical within-role ratios into a misleading pooled headline. Two different models can bill different token counts for nearly identical input, because the token meter itself is scoped to whichever model is doing the counting. All three of those are stories about a number that comes back wrong, or a number that's right but not comparable to the number sitting next to it.

This one has no number to begin with, wrong or otherwise. The cost wasn't measured incorrectly — it was never inside the measurement's field of view. A tool-call audit counts what runs through the tools it's watching, and a hook doesn't run through Bash; it runs through a layer that audit was never pointed at. Worse, when the hook succeeds it produces no output at all, so there's nothing for even a hook-aware audit to add up except the failures. This isn't an instrument distorting a reading. It's a cost source sitting on a wire nobody had an instrument on.

The mechanism

The hook itself was simple, which is exactly why it had been running unexamined. Every time an agent edited src/<mod>.py, a PostToolUse hook ran the entire corresponding suite, tests/test_<mod>.py — a run costing anywhere from two to three and a half minutes — and only spoke up, waking the model with a failure message, if something in that suite came back red. A clean run produced nothing: no log line, no transcript entry, no evidence that anything had happened.

Why a silent, per-edit hook is worse than it sounds

Three things stacked on top of each other here, and any one alone would have been tolerable.

First, it's invisible to the audit method you'd normally reach for. An audit that counts tool calls counts Bash invocations, API calls, background processes it already knows to watch — not hooks, and especially not a hook whose success path is silent. All you can ever recover after the fact is a lower bound, built out of whichever failures were loud enough to leave a trace.

Second, and worse than the blind spot, the hook was scoring the wrong moment. A change that touches multiple files is, by construction, broken partway through: you edit the first file, and until you've also edited the second, the tests covering the first file are correctly red. That redness is exactly what wakes the hook into failure mode, and the pressure it creates is "fix this now." Faced with that pressure, the model reverts or routes around the file it just touched — the one already edited — rather than moving on to the second file it hasn't gotten to yet, the one that would have actually made the suite pass.

Third, it was a cost nobody had asked for. Neither I nor the model triggered these runs on purpose — the hook fired on its own, off an edit event — which means nobody was ever in a position to ask "why is this running," the question that would normally catch a wasteful process before it repeats hundreds of times.

The numbers

Counting up what the transcripts actually showed over the 24-day window: 971 failed hook runs, totaling 5.0 hours. Edits in the main session alone were enough to trigger the hook 533 times — before adding in whatever the sub-agent workers triggered on their own edits, which ran through separate transcripts not folded into that count. The true number of times the hook fired, successes and failures combined, isn't something I can reconstruct after the fact; silence doesn't leave a receipt.

The per-file cost behind those numbers: test_cli took 216 seconds, test_map_scenes took 160, test_compliance_gate took 134 — every one of those running in full, after every single edit to the file it covered.

After the fix, a smoke test told the other side of the story: a queue of 3 pending files drained down to pytest running against 2 files, in 4 seconds.

The fix: move the trigger from the editor to the author

The fix keeps the same suite and the same enforcement intent — verification still has to happen — and only changes when it fires.

The PostToolUse hook now does one thing: it appends the path of the file that was just edited to a queue file. That's a write of a few milliseconds, not a multi-minute test run.

A Stop/SubagentStop hook — the one that fires when a turn actually ends — reads that queue, de-duplicates it, and runs pytest exactly once, covering every file touched during the turn. It returns a summary to the model only if something in that single combined run fails. A turn with no edits costs nothing, because the queue is empty and there's nothing to drain. A lock keeps two drains from overlapping, and an empty queue is its own stopping condition, so there's no path to the drain looping on itself.

One part of this was easy to get backwards: hook configuration is snapshotted at session start. Change the hook definition mid-session, and the session you're in keeps running on the old snapshot — the new behavior only takes effect starting the next session. That's worth flagging on its own, because it's exactly the kind of fix that looks broken if you test it in the same session where you wrote it.

What generalizes

If you've established a rule like "verification happens once per unit of work," the first thing to go looking for isn't more verification — it's the automation that's already quietly breaking that rule. A written rule changes what a person or a model does next. It does nothing to a hook, because a hook doesn't read rules; it reads trigger events, and it keeps firing on the event it was configured for regardless of what the team has since agreed the right cadence should be.

The practical corollary is about how you audit time and cost in an agent fleet at all: tool calls are not the whole cost surface. Hook output and background processes are part of it too, and a hook whose success path is silent means any total computed from logs is a floor, not a figure — bounded below by whichever failures happened to be loud enough to record themselves, and unbounded above by however many quiet successes never got the chance.

And the timing question generalizes past testing specifically. The right moment to verify a change is almost never "immediately after this one edit." It's "after this one unit of change is finished" — and knowing where that boundary sits isn't something an editor or a file-save event can know. Only whoever is actually authoring the change, and deciding when a turn is done, is in a position to say so.

Where this would turn out to be wrong

The falsifier here is specific enough to check for directly: in the next batch of work, if the end-of-turn drain still comes back red at roughly the same rate the old per-edit hook did — because of the same mid-edit, still-incomplete state — then the fix didn't actually solve the problem it looks like it solved. That result would mean the defect was never about when the check runs. It would mean the test files themselves are too coarse a unit, bundling too much unrelated behavior into one suite for any single trigger point to time correctly. At that point the next move isn't to move the hook again — it's to split the suite.

FAQ

Why didn't a standard tool-call or turn audit catch a hook that was costing 5 hours of test runs?

Because the hook doesn't run through Bash or any tool the audit was counting — it runs through a PostToolUse layer the audit was never pointed at, and a passing run produces no output at all. Only failures leave a trace: 971 failed runs totaling 5.0 hours showed up in the transcripts, while the true count of successful, silent runs was uncountable, and edits in the main session alone were enough to trigger the hook 533 times before adding whatever the sub-agent workers triggered on their own edits.

Why does running the full test suite right after every single-file edit cause more harm than good?

Because a change that spans multiple files is correctly broken partway through — editing the first file leaves its tests red until the second file is also edited. A hook that fires on every edit catches that expected, temporary redness and wakes the model with a failure message, and the model's response is to revert or route around the file it just touched rather than finish the second file that would have made the suite pass.

What exactly changed between the old per-edit hook and the fix?

The PostToolUse hook now only appends the edited file's path to a queue file, a few milliseconds of work. A Stop/SubagentStop hook, which fires once at the end of a turn, de-duplicates that queue and runs pytest exactly once across every file touched in the turn, returning a summary only if the combined run fails. Turns with no edits cost nothing, a lock prevents two drains from overlapping, and an empty queue is its own stopping condition.

Why might a hook change not seem to take effect right after making it?

Because hook configuration is snapshotted at session start. Editing the hook definition mid-session doesn't change the behavior of the session you're in — the new configuration only takes effect starting the next session, so testing the fix in the same session where it was written can look like it failed when it actually just hasn't loaded yet.

How would you know if this fix actually solved the problem, rather than just moving it?

By watching whether the end-of-turn drain still turns up red at close to the same rate the old per-edit hook did, because of the same mid-edit, still-incomplete state. If that rate doesn't drop, the defect was never about timing — it means the test files are too coarse a unit for any single trigger point to time correctly, and the next step is splitting the suite, not relocating the hook again.

Related notes

Fixed your problem? Good — that's the whole point of this page. Nothing here is gated. I write one of these up whenever I hit a failure worth recording: agent-fleet operations, harness bugs, and the measurement that proved the fix. Get new notes by email.
← hexisteme · notes · about · editorial policy · privacy · contact · CC-BY 4.0