Dispatch and capacity
Why work went to one place and not another, or nowhere — and the refusal classes about starting a process, a server or an interactive session.
21 classes. 4 explained, 17 named only.
⚠ Read this first, because it is the part of the section most likely to mislead you. Dispatch refusals — the ones that decide where a piece of work runs, or that it will not run — are the refusals an operator meets most often, and they are not identified by a class from this list. They are identified by the rule the refusal names, and that rule is the whole diagnosis. The classes on this page are a different, narrower set: they are about getting a process, a server or an interactive session up and reachable, not about choosing where work lands.
So there are two different things to look up, and knowing which one you are holding saves the search.
If your refusal carries a rule number
Every dispatch refusal names its rule and says what it saw. A refusal costs nothing — no budget is spent, no work is lost, nothing is left half-written. When in doubt, attempt it and read the answer; that is cheaper and more reliable than reasoning about whether it would have worked.
The ones an operator meets most:
| you see | it means | what to do |
|---|---|---|
| the seat is busy | that exact seat is running something | wait, or name another seat |
| the lane is exhausted | the vendor's budget ran out during the run | the run is lost; wait for the window to reset |
| the seat is spent and an alternative is named | this seat's budget is nearly gone and a healthier one exists | dispatch onto the named seat |
| the seat is spent and it was admitted with a warning | budget nearly gone, and no healthier seat of that backend exists | proceed knowingly, or wait for the reset |
| usage is unknown | no reading is available for this seat | not a fault — the check abstains rather than guessing |
| trust is not stamped | this host has not been granted trust for this workspace | a one-time setup act, per host and per workspace. This is a human act. |
| the repository is not fresh | the host's copy of the code is behind | update the host |
| an owner block | a human deliberately blocked this | ask the human |
⚠ Absence of a measurement is not a refusal. A seat with no reading is admitted with a note. And a row that reads unmeasured is not a row that reads refused — the first means nobody has looked, the second means someone looked and said no.
Four things that surprise operators
- "It refused, but the seat looks free." Busy is measured at the moment of dispatch, and several things can hold a seat besides a visible run: a lock from a sibling attempt, a host ceiling reached by other seats, a role already out.
- "It picked a tired seat over a fresh one." The redirect only applies when the fresh seat shares the tired one's backend. A fresh seat of a different vendor is not an alternative.
- "The same work refused on three different seats in a row." Then the objection is probably not about seats at all. Trust and workspace conditions are bound to the host and workspace, so changing seats does not clear them.
- "Two identical requests behaved differently an hour apart." Readings age. A seat measured as healthy at nine may be spent by ten, and the check reads the latest reading, not the one you saw.
⛔ Read the refusal before you change anything. Every refusal in this system is written to name what it saw; changing a setting to make a sentence go away is how a correct refusal becomes an incorrect configuration.
Starting the server
The listen address leaves the host implicit
Class: listen-ambiguous.
What it means. A listen address such as :7654 names a port and no host, and that form binds
every interface. Widening beyond loopback must be a deliberate act, so the server refuses to
start — whether the value came from --listen, SAPHAN_SERVER_LISTEN or the server.listen key.
What to do. Name the host. The refusal prints both choices with your port filled in:
127.0.0.1:<port> for loopback, or 0.0.0.0:<port> to bind all interfaces explicitly, and it
names which setting the value came from.
--detach together with --stdio
Class: detach-stdio-conflict.
What it means. --stdio hands the MCP wire to the terminal that started the server, for the
life of the process; a detached process has no such terminal. Nothing is launched.
What to do. Use --detach alone for a background server with an HTTP listener, or --stdio
alone in the foreground.
--tunnel together with --stdio
Class: tunnel-needs-listener.
What it means. A tunnel fronts the HTTP listener, and --stdio starts none. Nothing is
launched.
What to do. Drop --stdio, or drop --tunnel. See
Transports for what a tunnel is and is not for.
--detach with a port the system would choose
Class: detach-kernel-port.
What it means. A listen address with port 0 lets the operating system pick the port when the
server binds — inside the detached process, after the command that started it can no longer see
which port it got, confirm it came up, or print an address anyone could reach. It is refused
before anything is launched.
What to do. Give an explicit port, or start in the foreground, which reports the port it actually received.
Named, not yet explained
These classes are real, deliberate and named. This section does not yet carry their resolution.
| class | what its name indicates |
|---|---|
routing-refusal | Work was not routed anywhere. ⚠ If your refusal also names a rule, that rule is the diagnosis — see the table above. |
toolchain-unavailable | saphan verify-stream found go, gofmt or git missing from the path, and ran nothing. ⚠ This is a property of the host, not of your request. |
result-too-large | A response from the MCP server exceeded its byte cap and was refused rather than truncated. The message names how to narrow the call; see the response budget. |
serverd-already-running | The background service is already running: a live process holds its pidfile. A stale pidfile is taken over without a refusal. |
gateway-mtls-listen-not-served | The gateway was started with mtls_listen set in its [gateway] section, and this build serves no such listener. It refuses to start rather than run without the mutually authenticated listener you configured. |
Interactive sessions — saphan cli and saphan run --interactive
| class | what its name indicates |
|---|---|
cli-seat-unknown | The seat named is not seated anywhere in this workspace's registry. |
cli-seat-not-local | The seat's machine is reached over SSH; this build opens no remote interactive session. |
cli-seat-retired | The seat's machine is retired. |
cli-role-mismatch | The seat is registered for a different role than the one asked for. |
cli-binary-unregistered | The seat's machine registers no binary for the seat's backend. It is never looked up on your path instead. |
cli-binary-unexecutable | The registered binary is missing, a directory, or not executable on this host. |
cli-profile-home-unresolved | The seat's profile directory does not resolve to an absolute directory on this host. |
cli-interactive-flag-conflict | --interactive was given together with a flag that only applies to an unattended run, or with a role it does not start. |
cli-shell-not-installed | saphan cli found no saphan-shell beside this binary. It is never looked up on your path. |
cli-backend-not-interactive | The seat's backend has no vendor command-line tool to enter — for example a seat bound to an API lane — or is one this build does not know. |
cli-registry-unreadable | The workspace's machine registry could not be loaded, so no seat can be resolved. |
cli-session-record-unreadable | Resuming a session failed because its record could not be read — not because the seat is unknown. |