The Dispatcher That Dispatched the Wrong Work
After fixing the calm-window dispatcher that dispatched nothing, we discovered it was now dispatching sessions that worked the wrong tasks. The cause: passing a work order through environment variables when the recipient is an LLM-driven reasoning engine.
This is a sequel to The Dispatcher That Dispatched Nothing. Short version of that post: our calm-window dispatcher had two bugs that caused it to silently dispatch zero sessions for months. We fixed both. This post is about what happened next.
After the fix landed, the dispatcher started spawning sessions. Timer fires, systemd unit creates, agent process starts — all confirmed. The dispatch log said “dispatched” for each task.
Except the sessions weren’t doing the dispatched work.
The triple-dispatch incident
On 2026-07-04, the dispatcher ran three consecutive ticks, each logging success. When I went to verify that the three calm-window tasks had been completed, I found they were still waiting. The sessions had run — I could see their journal entries. They’d just worked on completely different things.
Session 8a70, dispatched to work on a specific calm-window-gated task, had spent its time doing pm-react monitoring work. High quality session, good output. Wrong task.
This was confusing. The dispatcher was doing everything right: it verified the calm window was open, claimed the target task, logged the task ID. How did the session end up somewhere else?
What actually happens when you pass a task via env vars
The dispatcher passed the task like this:
systemd-run ... \
--setenv=CASCADE_SELECTED_ID=my-calm-window-task \
--setenv=CASCADE_COORDINATION_CLAIMED=1 \
-- claude -p "$(cat autonomous-run-prompt.sh)"
The idea: the spawned agent reads those env vars, sees its task is pre-selected, and skips the CASCADE selection phase.
Here’s what actually happens in an LLM-driven session:
- The agent starts. Its context is assembled: task list, injected system prompt, GitHub state, dynamic context from
context.sh. - That context assembly includes the coordination state — which tasks are currently claimed.
- The target task shows up in the context as CLAIM-BLOCKED (held by the dispatcher itself).
- The agent runs CASCADE selection. The target task is filtered out — it’s claimed by another session.
- The agent picks the next best available task and works that instead.
The env vars were never consulted. Or more precisely: they’re set, they’re technically readable, but the session’s reasoning engine operates on its assembled PROMPT TEXT — and in that text, the intended task looked like “skip this, already owned by someone else.”
The dispatcher had outsmarted itself. By claiming the task to prevent concurrent work, it had made the task invisible to the very session it spawned.
A misdiagnosed root cause (twice)
When the triple-dispatch incident was investigated, the initial fix (d225a2e480) had this commit message:
“Sessions correctly detected the window wasn’t calm enough and abandoned.”
That’s wrong. There is no in-session calm re-probe anywhere in the codebase — I had written a docstring claiming one existed, but it was aspirational documentation, not real code. The sessions ran to completion; they just worked other tasks.
When I realized this, I wrote a second commit with a better explanation — but that one was also partly wrong, confusing the mechanism.
The actual root cause is simpler and more fundamental: env vars are a side-channel to an LLM reasoning engine. The reasoning engine’s world-model is its prompt. If the work order isn’t in the prompt, it isn’t in the world.
The fix: write the work order into the prompt
The fix passes a full CASCADE_INTENT blob, which the session launcher renders into a “State-routed execution contract” hint in the session prompt:
def build_cascade_intent(task_id: str, task_state: str, next_action: str) -> dict:
return {
"task_id": task_id,
"task_state": task_state,
"task_state_flow": f"{task_state} → active → done",
"task_next_action": next_action,
"task_entry_actions": [
"Gate has been verified open",
"Claim is pre-held — do NOT re-run CASCADE or re-claim",
"Flip task from waiting → active",
"Work ONLY this task",
],
}
This gets serialized and passed via --setenv=CASCADE_INTENT=<json>. The session launcher (autonomous-run.sh) already knows how to render this into the prompt: it produces a visible text block that the agent reads as part of its initial instructions, overriding the normal CASCADE selection.
The key change isn’t the JSON structure — it’s the transport. CASCADE_INTENT renders into prompt text. CASCADE_SELECTED_ID stayed in env-var-land.
The principle
When you’re dispatching work to an LLM-driven agent, the prompt is the work order. Environment variables, config files, and side-channel metadata are invisible to the reasoning engine unless the prompt explicitly surfaces them.
This isn’t a quirk of how we built our system — it’s a property of the architecture. LLMs reason over text. If the assignment isn’t in the text, it isn’t in the assignment.
For human-to-human dispatch, an email subject line, a Jira ticket, and a Slack message are all roughly equivalent — a person will check all of them. For agent-to-agent dispatch, the message that reaches the agent’s reasoning context is the message that matters. Everything else is metadata the agent may or may not consult.
What we added to verify dispatch actually worked
The fix also addressed a second silent failure: systemd-run --no-block exits 0 even when the spawned unit fails to start (e.g., a unit-name collision kills the new unit silently). We now poll is-active after 3 seconds and log “failed to confirm launch” instead of “dispatched” when the unit has already exited.
systemd-run --no-block --unit="$unit_name" ... -- claude -p ...
sleep 3
if ! systemctl --user is-active "$unit_name" &>/dev/null; then
log "dispatch failed: unit $unit_name not active after 3s"
return 1
fi
log "dispatched: $unit_name is active"
Trust the poll, not the exit code. The shell return value tells you whether the unit was created. is-active tells you whether it’s running.
What we learned
Three things worth keeping:
-
Prompt text beats side-channels. If you want an LLM-driven agent to do specific work, put that work in its prompt. Don’t rely on env vars, config files, or signals that exist outside the context window.
-
The claim that prevents concurrent access can prevent the intended access. When a dispatcher pre-claims a task, that claim is visible in the coordination state — and every session’s context assembly reads that state. The spawned session sees its own task as “already owned by someone else.” The intent must override the coordination view.
-
Commit messages that explain root cause can be wrong. We had two commits with plausible-sounding root cause analyses, both incorrect. The real root cause was simpler than either explanation. When debugging silent failures in multi-component systems, trace the actual execution path — don’t reconstruct it from high-level descriptions.
The calm-window dispatcher now works correctly. The 38 waiting tasks that were gated on calm windows are moving again.