# Dispatch mechanics
How the `/dispatch` funnel actually moves code — the fork→verify→import→land
pipeline, the git-plumbing invariants it rests on, and the failure modes that
have cost real respawns. This is the *explain* tier: read it once to build the
mental model. For the sharp mid-operation traps you hit *while* driving a
dispatch, retrieve `mem.signpost.doctrine.dispatch`; for command shapes ask
`doctrine <command> --help`.
The orchestrator is the sole writer. Workers execute one phase inside an
isolated worktree and hand back a single source-delta; the orchestrator imports
that delta, verifies, and commits. Everything below protects that contract.
## The fork base is explicit, never the session HEAD
A worker's fork **must** be created from the explicit coordination base `B` —
the orchestrator's coordination-branch HEAD captured pre-spawn — never from the
implicit current/session HEAD.
The session repo may sit on a different branch (e.g. `main`) than the
coordination branch, so **session HEAD ≠ `B`**. A fork that inherits the
implicit HEAD lands on a divergent base: `S.parent != B`, and the net diff
`B..S` then smuggles the session↔coordination divergence into the import —
unrelated commits ride into the wrong slice's delta.
Two belts enforce it:
- **Worker baseline guard.** Immediately after `git worktree add <dir> <branch>
<B>`, assert `git -C <dir> rev-parse HEAD == B` and abort otherwise.
- **Orchestrator import guard (trusted side).** Assert `git rev-parse S^ == B`
before applying the delta — catches a misbased fork even if the worker skipped
its own guard.
Harness trap: some spawn backends build the fork from the session HEAD and give
no reliable base control (the claude `Agent` tool at `isolation: worktree` is
one). Where the backend won't honour `B`, spawn a plain agent that self-forks
from `B` explicitly rather than trusting the backend's isolation.
## Verify scope: never run the project-wide gate in the funnel
Funnel and worker verification must be **scoped to the touched files**, not the
whole tree. A project-wide format gate (e.g. `cargo fmt` inside `just gate`)
reformats in place across the crate and pulls unrelated pre-existing drift into
the worker's delta — a base commit that isn't format-clean under the pinned
toolchain will smuggle format churn into every slice.
Scope it: lint + test + a `--check`-only formatter over the touched files. Prove
the phase, don't reformat the world.
## Two ways a worker returns its delta: gated self-commit vs working-tree diff
The worker cannot run raw `git commit` — the linked worktree's `.git` is
read-only (jail wall). Two arms clear that wall differently:
- **claude arm — gated server-side self-commit.** The worker calls the
`worker_commit` MCP tool, passing only its own opaque `agent` id (its worktree
name — **never a path**). The *unconfined* server resolves that id to the
worker's worktree and lands the commit on its behalf, so the jailed worker never
touches `.git` directly. This is a deliberate, single-purpose bypass of the jail
wall — therefore the tool's **belts are the security boundary**, not the wall:
non-empty pre-fmt delta → two-tier scope (a HARD forbidden-zone that hard-refuses
any write under `.doctrine/`, `.claude/`, or the configured
`[dispatch].worker-forbidden-writes`, plus a SOFT undeclared-path report) →
`HEAD == B` → the `check commit` gate → exactly one non-merge commit `C`
(`C^ == B`) on the worker's own `dispatch/<agent>` branch. A spoofed sibling id
commits to the *sibling's* branch and leaves its own at `B`.
- **subprocess (pi) arm — working-tree diff.** The worker cannot self-commit at
all; it hands the tree back and the orchestrator captures the working-tree diff
(`import --from-worktree`). This is also the fallback when the MCP server is down.
The orchestrator then imports. On the claude arm it imports the **commit**
(`import --fork <C> --branch dispatch/<agent>`); the `--branch` coherence belt
binds the import to the branch the orchestrator *armed*, so it promotes nothing of
a poisoner who committed to a sibling's branch. `verify-worker` accepts the
post-commit `HEAD` because it tests `merge-base --is-ancestor B HEAD` (a
descendant), not `HEAD == B`. Either arm's import is **non-committing** (next
section) — the delta is diff-applied, and the orchestrator commits separately.
Ask `doctrine worktree --help` / `doctrine mcp` for exact flags and the tool's
refusal tokens.
## The import severs ancestry — so "did it land?" needs a patch-id oracle
The funnel imports a worker's delta with a non-committing 3-way apply onto `B`,
then the orchestrator commits separately. This **severs git ancestry**: the fork
branch `S` is never an ancestor of the coordination commit. Every naive
landed-oracle is therefore unsound:
- `git branch --merged` — the apply-funnel branch is never merged, always
reported unmerged (and `git branch -d` always refuses it; deletion needs `-D`).
- **Delta-emptiness** (`git diff B..fork` empty ⇒ landed) — `B..fork` is the
whole worker delta, never empty for real work, so it refuses every fork. And
the moment a sibling moves the coordination HEAD, `HEAD..fork` legitimately
diverges ⇒ non-empty ⇒ also refuses a spent fork. Either way the operator
learns a `--force` reflex and the safety gate collapses.
- **A runtime-tier "import receipt"** stamped on apply-success — unsound too. It
certifies the *apply*, not the *commit*: it is born before the separate commit,
lives in disposable state, and survives a crash-before-commit — reading
"landed" when no commit ever reached the branch, so a recovery-time cleanup
reaps the only surviving copy of unmerged work. A flag in disposable state must
never gate an irreversible `branch -D`.
**Sound oracle: a durable patch-id check.** Run `git cherry <coordination-HEAD>
<fork-branch>` and treat the fork as landed **only when every commit in its
`B..fork` range is marked `-`** (its patch is already present in coordination's
history by patch-id). Any `+` ⇒ not fully landed ⇒ refuse. This is keyed on
durable git state *after* the commit, so it is crash-proof (a crash before the
commit leaves no landed patch ⇒ `+` ⇒ refuse), robust to a sibling moving HEAD
(patch-id matches the commit's patch, not a whole-tree diff), and robust to the
apply severing ancestry (patch-id ≠ ancestry). Ranging over *all* commits (not a
single tip) lets one oracle serve both the single-commit dispatch fork and the
multi-commit solo fork.
### The squash blind spot
The patch-id oracle **cannot** distinguish a *multi-commit* squash-merge from a
fork that never landed. A multi-commit `git merge --squash` produces one squash
commit whose patch-id matches none of the fork's individual commits, so `git
cherry` lists every commit `+` and the tip is not an ancestor — byte-for-byte the
signal of a fork that never landed. (A *single*-commit squash is fine: its
patch-id equals the squash commit's, so `git cherry` marks it `-` and certifies
it landed — the blind spot is strictly the multi-commit case.) Do not build a
squash detector; it cannot exist.
Collapse squash + never-landed into one `not-landed` refusal whose message names
both remedies. This is the load-bearing reason a solo fork must land via a
non-squash (`--no-ff`) verb: squash destroys the oracle.
## Landing on a shared trunk races — report and halt, never auto-merge
Integrating onto a live shared trunk is fast-forward-only plus an expected-tip
compare-and-swap. On a trunk where other agents commit concurrently, two
failures bite — both report-and-halt by design:
1. **Trunk moved mid-command.** The admitted target's base went stale between
"create candidate" and "integrate". Fix: re-create the candidate superseding
the prior on the new base, re-admit, re-integrate. To shrink the race window,
chain create→admit→integrate in one shell invocation (the candidate ref name
is deterministic, so nothing needs threading). Expect retries under churn.
2. **Dirty-worktree refusal.** Integrate resyncs the live checkout and refuses a
blanket-dirty tree — even when the dirty file is another slice's authored WIP
that cannot conflict with the projected code. You may not stash or discard
another agent's uncommitted work. Resolution is the work's owner committing
it, then re-superseding (their commit advanced trunk) and integrating. The
driver cannot self-unblock — surface it and wait.
The trunk ladder defaults to the remote's `origin/HEAD`, which lags a local
trunk; point dispatch verbs at the intended local trunk ref explicitly.
## Worker-spawn identity is accident-fenced, not fail-closed
A `SubagentStart`-style spawn hook that stamps a worker-identity marker runs
synchronously (the marker is present before the worker's first command *when the
hook succeeds*) but is **read-only** — it cannot abort the subagent on failure.
On a stamp failure the worker proceeds unstamped and un-gateable by the hook. So
worker identity must be fenced by the **import belt + a worker-mode env guard +
the pre-distilled prompt**, never by the hook's exit status. The only
fail-closed-capable creation seam is a worktree-creation hook (non-zero exit
aborts creation), preferable *where the harness exposes it with enough payload to
act on* — often it does not: a creation hook whose payload lacks the worker's
type/path can neither scope its check nor identify what to abort, so it stays
deferred and the belt-plus-guard fence remains the default.
## Workers can silently discard their own work
A worker may build a phase correctly (tests green) and then `git reset` /
`checkout -- ` / `stash` / `clean` the entire delta away — hallucinating a
"pre-existing WIP", reverting its own edits, and never committing. The fork comes
back clean (HEAD == B, empty diff). Fence it at the prompt:
- State the fork is a **clean** checkout with **no** WIP and that the target
files do not yet exist.
- Forbid every work-discarding git verb; the only git the worker runs is the
final `git add <paths>` + `commit`.
- For a red-proof reversion (TDD), instruct it to *edit* the scratch out, never
to git-discard it.
And never trust the worker's self-reported success — the fork's committed git
state is the ground truth. Re-read the commit, re-run the suite; distrust the
handover's own green/failure labels.
## Subprocess-arm RPC hygiene (codex/pi)
When the worker is a subprocess speaking a line-oriented RPC:
- **One compact JSON object per line.** A pretty-printed prompt message emits
multi-line JSON and every line fails to parse — the prompt never lands and the
worker sits idle. Build RPC lines compact (single-line).
- **The process may park on success, not self-exit.** It emits a completion
event but blocks on stdin, so a timeout-bound spawn burns the full timeout even
though work finished in minutes. Run a watcher that polls the log for the
completion event and kills the process on match, so the spawn returns at
completion rather than at timeout.
## See also
- `mem.signpost.doctrine.dispatch` — the retrieval index for the sharp
mid-operation traps (retrieve *during* a dispatch, not up front).
- ADR-006 (worktree posture), ADR-008 (jail isolation), ADR-011 (harness-agnostic
spawn), ADR-012 (integration topology) — the decisions behind this machinery.
- `doctrine dispatch --help`, `doctrine worktree --help` — exact command shapes.