building vodou.

Your commit message claimed a call path that doesn't exist

A tool fan went from 770ms wall to 633ms against 1398ms of work. The commit said it fixed the chat path. The changed code had two callers, both CLI-only.

Chad Priest / / 8 min read

On August 24 I wrote two numbers into a run log: 770 ms of wall clock for a fan of parallel tool calls, and 763 ms for the sum of the individual branch durations. A fan whose wall time equals the sum of its branches is not a fan. It is a for loop wearing a costume.

I fixed it. Two weeks later I found out the fix landed on a code path that nothing in the product calls, and that the commit message announcing it was wrong about that in a way no test, no linter, and no reviewer would ever have caught. The concurrency bug took a day. The wrong sentence in the commit message survived twelve.

770 ms wall, 763 ms of branches: a fan that never fanned

The setup is common enough that you probably have one. Several MCP tool calls get issued at once, each one going out over stdio: write a request frame, block until the response frame comes back. The fan wrapped those calls in futures::join_all and awaited the lot.

join_all over a blocking leaf is not parallelism. It is a queue. Each future grabs the executor thread, blocks it on a synchronous write-then-read, and hands it to the next one. The async keyword is load-bearing for exactly nothing here. If you want the phrase that gets you to the answer at 2am: futures::join_all is not running my tasks in parallel because the thing underneath the await is not yielding.

The measurement that catches this is four lines, and it is the same four lines in any language. Time each branch, time the whole fan, print both:

const t0 = Date.now();
const branch = [];
const out = await Promise.all(calls.map(async c => {
  const s = Date.now();
  const r = await client.callTool(c);   // may be blocking underneath
  branch.push(Date.now() - s);
  return r;
}));
const sum = branch.reduce((a, b) => a + b, 0);
console.error(`[FAN] wall=${Date.now() - t0}ms sum=${sum}ms max=${Math.max(...branch)}ms`);

If wall / sum is near 1.0, you have no parallelism. If wall is near max, you do. My ratio was 1.01. The fix in the engine was to move each branch onto a blocking-safe thread pool (spawn_blocking, one call per branch) instead of pretending the leaf was async, while keeping input order in the results and settling a panicked branch as a failed value rather than poisoning the join.

The 2.2x that reached nobody: 633 ms against 1398 ms of branches

After the change the same fan measured 633 ms of wall clock against 1398 ms of summed branch time. A real 2.2x. I committed it as ef17edec and wrote a commit message that described the blast radius, because good commit messages describe why and where, and I have read all the same posts you have. Silicon Opera is right that the paragraph is what survives into next year while the diff becomes archaeology. What that post does not say, and what nearly took me out, is that the diff is checked by CI and the paragraph next to it is checked by nobody.

My paragraph said the fix removed a latency tax from the chat path. Every artifact I had agreed with it. A note from March in my own memory store said “BrainLoader to workflow driver to parallel tool execution pipeline confirmed working (7/9 intent routes functional).” Another said multi-intent parallel execution completed in “~627ms for 4-5 simultaneous tool calls.” Two independent prose assertions that live product traffic reached this code. Both mine. Neither ever produced by a tool.

Then chat latency did not move. Not a little. Not noisily. It did not move at all.

Two callers, both behind a CLI subcommand: what ef17edec actually shipped

I stopped reading code and made the function announce itself. One line at the top of the fan that captured a backtrace and logged it, then I exercised everything: gateway chat, the extension capture path, the scheduler, a normal multi-tool prompt. Zero hits. Not one.

The static count agreed. The fan had exactly two callers, both of them methods on the intent router, and both reachable only from the parallel and parallel-custom subcommands of our own CLI. Those subcommands are a developer surface. No user request in any product surface has ever entered them. The parallel tool executor I had just made 2.2x faster was, in production terms, dead code with excellent latency.

The worst part is the propagation. On August 26 I had filed a QA note saying “the sequential fan is fixed only in the graph path; BrainLoader multi-intent still calls the old executor and still pays the sum of its branches.” That note is also wrong, and it is wrong for the same reason: I inherited the reach claim from ef17edec’s message and wrote a follow-up on top of it instead of checking. One unverified sentence became two, and the second one had a QA ticket number on it, which made it look like evidence.

The correction, once it existed, was six lines and cost me a day of measurement I should have spent on August 23.

Over-claiming a blast radius is silent; under-claiming is loud

Here is the class, with none of my nouns in it: a change is scoped by a human-written sentence about which code paths it touches, and nothing in the pipeline ever compares that sentence to the call graph. The code is correct. The tests pass. The claim about reach is fiction, and it outlives the review that would have caught it, because it now lives in git log where the next person quotes it as fact.

You have this if you have an agent framework with two tool executors, one for the interactive loop and one for the batch runner, both named some variant of “executor”. You have it if you run an MCP server with both a stdio and an HTTP entrypoint and someone fixed “the request handler”. You have it in a Go or Rust monorepo where a subcommand-only path shares a module with the server path. You have it in any RAG retrieval layer shared between a nightly ingest job and live chat, where the risk note says “affects search quality” and means only one of them.

The standard advice about blast radius is that it is bigger than your diff. Ken Imoto’s post puts it well: the thing that breaks is a middleware two function calls away, and reading the diff cannot show it to you. That is true, and it does not cover my case, because my failure was the mirror image. The blast radius was not bigger than the diff. It was smaller than the commit message. Under-claiming reach is loud: something breaks, you get a page, you learn. Over-claiming reach is completely silent. Nothing breaks, because nothing runs. Every tool in this space is built to find the caller you missed. None of them raise anything when you name a caller that has no edge to your symbol at all.

The zof.ai piece on change impact analysis has the right primitive, the reachability set: “the difference between ‘this function changed’ and ‘this function is on a path that serves checkout.’” What it misses is lifecycle. A reachability set gets computed for a diff at review time and then thrown away, while the prose claim persists and gets cited by an audit six weeks later. The set is the artifact worth keeping, and almost nobody keeps it. code-impact-radius is the closest thing I have seen to the right shape for TypeScript: one command, transitive callers, JSON out. What is missing everywhere is the diff between that output and the sentence a human wrote.

The invariant, stated so a codebase either satisfies it or does not:

Every entrypoint named in a change’s stated blast radius must appear in the transitive caller set of the symbols that change touched. If it does not appear, the blast radius is fiction and must be deleted from the message, not softened.

Make the function you just fixed print its own callers

Two checks, both under five minutes, both against your own code.

Runtime, two minutes. Put a caller probe at the top of the function your last risky commit touched:

function reachProbe(tag) {
  const st = new Error().stack.split('\n').slice(2, 8).map(s => s.trim()).join(' <- ');
  console.error(`[REACH ${tag}] ${st}`);
}

Python: import traceback; print('[REACH]', traceback.format_stack()[-6:-1]). Then exercise every entrypoint your commit message names, for real, not in a unit test:

curl -s -XPOST localhost:8080/v1/chat -d '{"message":"do two things at once"}' > /dev/null
grep -c 'REACH' server.log

Passing looks like a non-zero count and your HTTP handler visible in the frames. Failing looks like 0. Zero, after you exercised the path your changelog swears you fixed, is the entire finding. That is what I got.

Static, three minutes. A name-based over-approximation of the caller closure, which is enough:

#!/usr/bin/env python3
# callers.py <symbol> [hops]  -> over-approximate the transitive caller set
import re, subprocess, sys, collections
DEFN = re.compile(r'^\s*(?:export\s+)?(?:async\s+)?(?:function|def|fn|func)\s+(\w+)'
                  r'|^\s*(\w+)\s*[:=]\s*(?:async\s*)?(?:function)?\s*\(')
EXCL = ['--exclude-dir=node_modules', '--exclude-dir=.git',
        '--exclude-dir=target', '--exclude-dir=dist']

def enclosing(path, lineno):
    lines = open(path, errors='ignore').read().splitlines()
    for i in range(min(lineno, len(lines)) - 1, -1, -1):
        m = DEFN.match(lines[i])
        if m: return m.group(1) or m.group(2)
    return f'<top-level {path}>'

frontier, seen, edges = {sys.argv[1]}, set(), collections.defaultdict(set)
for _ in range(int(sys.argv[2]) if len(sys.argv) > 2 else 3):
    nxt = set()
    for sym in frontier - seen:
        seen.add(sym)
        cmd = ['grep', '-rnE', r'\b' + re.escape(sym) + r'\s*\('] + EXCL + ['.']
        for h in subprocess.run(cmd, capture_output=True, text=True).stdout.splitlines():
            p = h.split(':', 2)
            if len(p) < 3 or not p[1].isdigit(): continue
            c = enclosing(p[0], int(p[1]))
            if c and c != sym:
                edges[sym].add(c); nxt.add(c)
    frontier = nxt
for k in sorted(edges):
    print(k, '<-', ', '.join(sorted(edges[k])))

It matches by name, ignores types, and will happily follow an unrelated method that shares a name. That is deliberate. It over-approximates, which means it errs toward more reach than you have, so a negative result is strong: if even this cannot walk from your request handler to the symbol you changed, the claim is dead. Passing output contains the handler your message names, somewhere in the closure. Failing output terminates in CLI subcommand registration and nowhere else, which is precisely what mine did.

Then go find the claims worth checking:

git log --since='90 days ago' -i --format='%h %s' \
  --grep='on the .* path' --grep='used by' --grep='called from' \
  --grep='affects' --grep='reaches' --grep='hot path'

Every hit is an assertion about your call graph that nothing verified. Run the script on the symbol each one touched. I had never verified a single one of mine.

The rule I now follow: do not write reach into a commit message until a tool has printed it. If the tool cannot print it, write the sentence you can actually defend, which is “callers: unverified.” That sentence is ugly and it is worth more than a confident one, because in six months somebody, probably you, is going to plan a release against it.