Your agent system probably starts in more ways than you think. There is the web server. There is the CLI. There are the editor hooks, the worker that picks up background tasks, the Docker image, the desktop wrapper, and the script you wrote for testing against a broken copy. Each one loads a slightly different set of processes and puts a slightly different set of context into the prompt. Ask your codebase which of those it is running right now. Most of the time the code has no word for the question.
Mine had no word for it. I build Vodou, a local-first AI operating system with persistent memory, retrieval, MCP tool orchestration and autonomous agents. I grepped every launcher for any run-mode concept: headless, a mode variable, profile. I got exactly one hit, and it was a false positive, a shell profile being sourced in the start script. The system ran seven ways, and those were really seven code paths that happened to share modules.
A missing prompt lane looked exactly like a broken one
This had a real cost. I keep a receipt for every turn that records which context lanes (memory, history, tool results, the system prompt and so on) went into the prompt. The CLI hook injects memory through one lane. The web gateway never does. On a gateway turn, the receipt showed that lane as absent, and "absent because this path never has it" rendered the same as "absent because the injector failed." With no place in the code to say that a run mode leaves a lane out on purpose, the observability layer couldn't tell a design decision from a bug.
The fix was to name the compositions. One file, stacks.toml, declares each stack: which processes it runs, which are optional, which lanes it injects, and every script that starts it. The engine loads that file and validates it against the two registries that already existed, one for processes and one for lanes. A command prints the result. Every shell entrypoint now exports the name of its stack, with the process environment still taking precedence so a caller can override it.
Where a run mode gets its name
The draft named ten processes and seven did not exist
I wrote the spec before touching code, then checked it against the tree. The spec got rewritten three times, and that turned out to be the main argument for doing the work.
The first draft listed ten processes. Seven of them didn't exist. I had written down names for things I thought of as processes: a core API, an MCP egress server, a board worker, a lab gateway, a brain, the rewrite, a llama server. Some were modules inside other processes. Some were plans. One ran in-process inside another binary. The draft was a picture of the architecture in my head, not the one on disk. The validator now fails any stack that names a process or lane the registries don't declare. I tested it by breaking stacks.toml on purpose and checking that it failed and printed the bad name.
The spec listed nine entrypoints. There are thirteen. Four started long-lived processes and belonged to no stack: session-start.sh (a detached wrapper), scripts/swap-binary.sh (which re-ensures the daemon with nohup), docker-gateway-service.sh, and one/cli/vodou1. The last one launches a process that had been in the process registry for weeks, even though no composition ever started it. So the registry and reality already disagreed before this work began. The plan said five stacks. It shipped with eight.
What the spec said vs what the tree had
I didn't want to guess the lane assignments, so I took them from evidence. Seven days of turn events showed the gateway emitting 17 distinct lanes and never the hook's memory lane. The hook emitted that lane (270 events) and nothing else. Nine declared lanes showed up on no path at all. I didn't mark those nine as dead, because "never observed" is also exactly what you would see from a producer that forgot to record. They stay unassigned. That forced two forms in the file. lanes_on is a closed set, used only where I actually know the full set. lanes_off is an open set that lists only the exclusions I can prove. A stack that declares both is rejected as a contradiction rather than merged.
Two unrelated bugs turned up along the way. The do, oi and vodou launchers were three byte-identical 349-line copies, kept in sync by a script that cp'd one over the others. I turned two of them into symlinks and deleted the sync script instead of keeping it, because the next run of it would have copied files over the links and brought back three copies that could drift apart. And install.sh had a chmod line that listed do twice and never oi, so ./oi wasn't executable on a fresh install. Nobody had noticed, because nobody runs oi right after installing.
Every script that starts a long-lived process belongs to one stack
Stated as something a codebase either satisfies or doesn't: every executable that starts a long-lived process belongs to exactly one declared stack, and every process and lane a stack names exists in its registry. When that holds, a missing lane in a trace can be read as either expected or broken. When it fails, every absence in your traces is ambiguous, and so is every "works on my machine."
I enforce the first half with a pre-commit guard. It deliberately does not grep for nohup. Four of the thirteen nohup occurrences in my tree were prose: an echo, a docstring and two comments. A guard that fires on documentation is a guard people learn to bypass. On its first audit it found three more launchers that belonged to no stack, including an installer that registers a launchd agent.
Count your own run modes in five minutes
Run this from your repo root. It lists files that start something that outlives the command, which is the shape that gives you an undeclared run mode.
# 1. Candidate launchers: things that detach, daemonize, or register a service
grep -rlE '(^|[^#].*)(nohup |setsid |disown|daemonize|detached: *true|launchctl (load|bootstrap)|systemctl (start|enable)|docker (run|compose up)|pm2 start)' \
--include='*.sh' --include='*.ts' --include='*.js' --include='*.py' --include='Dockerfile' . \
| grep -vE 'node_modules|/test|__tests__|\.md$' | sort > /tmp/launchers.txt
wc -l < /tmp/launchers.txt
# 2. Do any of them say what mode they are?
for f in $(cat /tmp/launchers.txt); do
grep -qiE 'stack|run_mode|RUN_MODE|APP_MODE' "$f" && echo "declares: $f" || echo "SILENT: $f"
done
Passing: every line reads declares:, and each name matches a list you keep in one place. Failing: any SILENT: line, or a count bigger than the number of run modes you would have said out loud. Mine was 13 against a spec of 9. Read each SILENT file by hand, because some of the hits will be comments.
Then check lanes, if you log prompt assembly at all. Assuming a table of prompt parts keyed by entry path:
SELECT entry_path, lane, COUNT(*) AS n
FROM prompt_parts
WHERE created_at > datetime('now', '-7 days')
GROUP BY entry_path, lane
ORDER BY lane, entry_path;
A lane that appears under one entry path and never under another is either a design decision or a bug. Passing: you can point to where that decision is written down. Failing: you can't, and your dashboard shows both cases the same way. If you can't write this query because nothing records the entry path, that is your answer.
Layer diagrams describe the stack vertically; run modes cut across it
Most writing about agent architecture describes layers. Kimlud's walkthrough goes from the model through memory, retrieval, tools and observability down to deployment. Stoney's seven levers order the pieces by what builds on what. Both are useful, and both assume one composition. The failure I hit runs horizontally: the same layers put together differently depending on which door you came in through, with observability missing the column that says which door.
Len van der Hof's STACK model is closer. It argues that agent failures are often repository debt and that rules, skills, MCP and hooks should be versioned infrastructure. I agree, and I would add launchers to that list. Anthropic's guidance to prefer simple, composable patterns holds up too. But composability is the reason run modes multiply without anyone noticing. Seven paths sharing modules felt like good reuse until I had to explain which one a trace came from.
Nine lanes in no stack, and an HTTP egress server nothing starts
Nine lanes still belong to no stack, because I have no evidence either way. The mcp stack declares an HTTP egress server as optional because nothing in the codebase actually starts it: the install command writes a client config pointing at a port that nobody is listening on. The registry now reports "not listening" instead of saying nothing, but that is a clearer description of the gap, not a fix. And the guard only checks launchers I can see in the repo. A launchd plist that someone installed by hand on their machine can't be caught by a commit hook.
If your agent stack runs in more than one mode and your traces can't tell "left out on purpose" from "failed," that is the gap this closes: every entrypoint declares its composition, the declaration is checked against what actually exists, and turn receipts know which lanes to expect. You can see it working in vodou.ai.
Source: Your agent runs more ways than your code knows about by Chad Priest, from Building Vodou in Public.


Top comments (0)