M3a review findings 3, 5 (the cast), 6, 7 (brokerd), 11. A config, directory or socket failure at start now ends with docs/runbook.md#brokerd-start-failed, and losing a listener with #brokerd-listener-lost; both entries are new. Threads start through thread::Builder, so a refused thread is reported instead of silently killing a listener; an aborted connection no longer stops the daemon. brokerd reads args_os and keeps the config path as a path. The "requester went away" result is recorded at the time it happens. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
17 KiB
Runbook
One entry for every state in which Boxmaker refuses to work or holds something back, and for
events worth understanding when you see them. Every message for such a state ends with
see docs/runbook.md#<entry>; a gate check makes sure the entry exists.
Each entry says what you see, why the system refuses, how to confirm, how to fix, and how to check the fix. Commands assume:
$BOXMAKER_HOMEis the harness home:/var/lib/boxmakerwhen deployed,.statein the repository during development.$GRANTSis[paths] grantsfrombrokerd.toml:/etc/boxmaker/grantswhen deployed.- Until M7 there are no service units: the daemons run in a terminal, and "stop" means Ctrl-C or
pkill -x <name>.
Entries for M3a states are written ahead of the code and describe it as specified in
docs/specs/2026-09-18-m3a-decision-path.md.
grants-invalid
What you see. brokerd prints one or more lines naming a grant file and a problem, then this
entry. Every tool call is denied, and the model tells you "the grant files have an error".
Why. One grant file is invalid, so brokerd denies everything until it is fixed. It cannot
skip the bad file: if the bad file was a deny grant, skipping it would allow what it was written
to forbid.
Confirm.
bxctl grants check
It prints each problem as <file>:<line>: <problem>. Common ones: a misspelled field (unknown
fields are errors); secret set (not supported until M4); patterns set (not supported); hosts
on a file tool or paths on http_fetch; a relative path or one with .., . or //; the path
/ (grant the directories you mean); a deny grant whose max_taint is not "secret" (it would
stop denying once the session read a secret); a host with uppercase letters, a port, or an IP
address; a file name with characters outside [a-z0-9-].
Fix. Edit the file, or move it out of $GRANTS while you work on it:
mkdir -p ~/grants-parked && mv "$GRANTS/<file>.toml" ~/grants-parked/
Do not park a deny grant unless you are sure nothing else would then allow what it forbids.
Check. bxctl grants check prints grants: ok. No restart is needed: brokerd reads the
grants again at the next call.
audit-unavailable
What you see. brokerd prints an error writing to $BOXMAKER_HOME/audit/, then this entry.
Tool calls are denied, and the model says "the audit log cannot be written". Every later call
prints brokerd: an earlier audit write failed; every call is denied until brokerd is restarted.
A call that ran but whose result could not be recorded reaches the model as "the result could not
be recorded", without its content; bxctl refuse says "the refusal could not be recorded".
A variant: brokerd: a thread panicked while holding the ledger. That is a bug in brokerd, not
a disk problem; the fix below is the same, and the panic message above it in brokerd's output is
worth keeping for a report.
Why. A call runs only after its decision is on disk. If the record cannot be written, nothing runs.
Confirm.
df -h "$BOXMAKER_HOME"
ls -ld "$BOXMAKER_HOME/audit"; ls -l "$BOXMAKER_HOME/audit" | tail -3
Look for a full disk, a directory or file not owned by the user brokerd runs as, or a read-only
file system.
Fix. Free space or correct ownership (chown to the brokerd user; files are mode 0600, the
directory 0700). Do not edit, move or delete audit files to make space: that breaks the chain.
Then stop and start brokerd. It does not try again by itself: a failed write may have left part
of a line, and only the startup check deals with that. Expect
audit-recovered at that start.
Check. Ask for any tool call. It is decided normally, and bxctl audit verify prints
audit: ok.
audit-chain-broken
What you see. brokerd refuses to start and prints <file>:<line>: <what>, then this entry.
Or bxctl audit verify prints the same and exits 1. If the break is in an older file, only
bxctl audit verify reports it: at an ordinary start brokerd checks the latest file alone.
Why. A record does not parse, its seq is not the next number, its prev is not the hash of
the line before, or a file does not continue from the one before it. Something changed the log
after it was written: an edit, a deleted or reordered line, a file restored from a backup, a
partial copy. brokerd will not add records after a history it cannot vouch for.
There is one innocent cause. After a crash brokerd recovers a torn last line by itself
(audit-recovered), but if it is killed a second time while writing that
recovery record, the log is left with a line that nothing describes. The failure is then within
the last three lines of the latest file, and the last line is cut short.
Confirm.
bxctl audit verify
sed -n '<line-1>,<line+1>p' "$BOXMAKER_HOME/audit/<file>"
Work out what happened before going on: a restore, a manual edit, a disk problem, or none you know of. If none you know of, treat it as a possible intrusion and keep the files as they are.
Fix. Keep a copy first, then accept the break explicitly:
cp -a "$BOXMAKER_HOME/audit" ~/audit-copy-$(date +%F)
brokerd serve --config <path> --accept-break
With this flag brokerd checks the whole log, not only the latest file, and appends an
accepted_break record naming the file and line of the first failure. It continues from there.
Nothing is repaired or deleted. The break stays visible in every later verification. One accepted
break covers everything between the failure and the break record, including any further damage
there, so look at that whole stretch before accepting it.
Check. bxctl audit verify prints audit: ok and lists the accepted break with its file and
line. Damage that happens after the break record needs its own accepted break.
audit-recovered
What you see. brokerd prints "audit: recovered a torn final line" at start, then this entry.
It starts normally.
Why. The last record was being written when brokerd or the machine stopped, so the file ended
without a newline. That is expected after a crash, not an error. brokerd kept the partial line,
ended it, and wrote a recovery record holding its length and hash, chained from the last complete
record. The partial line may even look like a whole record; it is not one, and the recovery record
takes its seq. It was never acted on: a record allows something only once it is on disk with its
newline. If the partial line was the result of a call, the call did run and its result was
never delivered; bxctl audit verify lists that call as unfinished.
Confirm. bxctl audit verify lists the recovery with its file and line.
Fix. None needed. If recoveries happen often, find out why brokerd is being killed.
Check. bxctl audit verify prints audit: ok.
brokerd-already-running
What you see. brokerd exits at start with "brokerd is already running" and this entry.
Why. Only one brokerd may write the audit log. It holds a lock on
$BOXMAKER_HOME/audit/.lock for as long as it runs, and the lock is released when the process
ends, however it ends.
Confirm.
pgrep -a brokerd
Fix. Use the running one, or stop it (pkill -x brokerd) and start again. Deleting .lock
does not help and is not needed.
Check. pgrep -a brokerd shows one process.
brokerd-start-failed
What you see. brokerd exits 1 at start, before it serves anything, with one line naming its
config file, a directory or a socket, then this entry. loopd then reports
broker-unavailable for every tool call.
Why. brokerd could not read or parse its config, could not create or make private (0700) the
directory a socket lives in, or could not bind a socket or make it private (0600). It will not
serve on a socket whose permissions it could not set, because those permissions are what keep other
programs off it.
Confirm. The line says which:
<config path>: …— the file is missing, unreadable, or not valid TOML forbrokerd.toml(unknown keys are errors). Check it againstdocs/specs/2026-09-18-m3a-decision-path.md, section 2, "Configuration".cannot prepare <dir>: …— the directory cannot be made orchmoded:ls -ld <dir> "$(dirname <dir>)". A path that runs through a file, or a directory owned by another user, gives this.cannot listen on <socket>: …—path must be shorter than SUN_LENmeans the socket path is longer than 107 bytes;Address already in usemeans something still listens there (ss -xlp | grep <socket>).
Fix. Correct the config, or the ownership of the directory, or choose a shorter socket path in
[sockets] (and the same path in loopd's [broker] socket). If another process holds the
socket, stop it; brokerd removes a stale socket file by itself.
Check. brokerd serve --config <path> prints brokerd: serving tools on … and approvals on ….
brokerd-listener-lost
What you see. Either brokerd exits 1 after it had been serving, with
brokerd: stopped serving: <error> and this entry; or it keeps running and prints
brokerd: cannot start a thread for a connection, so it was closed. In both cases loopd reports
broker-unavailable for the calls that were refused.
Why. The system refused brokerd something it needs to serve: accept failed on a socket for a
reason other than one aborted connection, or a thread could not be started. The usual cause is a
limit: open files (EMFILE), processes or threads for the user, or memory. A connection that is
refused gets no decision, so nothing runs for it.
Confirm.
ulimit -n; ulimit -u
ls /proc/$(pgrep -x brokerd)/fd | wc -l # while it runs
ps -o nlwp= -p $(pgrep -x brokerd) # its thread count
Many threads usually means many connections waiting on approvals, or a client that opens
connections and never sends: look at bxctl approvals and at which process holds the sockets
(ss -xp | grep broker).
Fix. Answer or let expire the pending approvals, stop whatever is flooding the socket, or raise
the limit. Then start brokerd again if it exited.
Check. brokerd prints serving tools on …, and a tool call is decided again.
broker-state-damaged
What you see. brokerd prints an error reading or writing
$BOXMAKER_HOME/broker/sessions/<id>.json, then this entry. Either every call for that session is
denied ("this session's broker state is damaged"), or one call failed with "the result could not
be recorded". Such a call has no Result record, so bxctl audit verify lists it under "running
or unfinished"; that is expected, not a second fault.
Why. The file holds the session's taint and untrusted flag. If brokerd cannot read it, it
does not know how sensitive the session's data is, so it denies. If it cannot write it after a
tool ran, it withholds the result: content that raised the taint must not reach the model unless
the raised taint is on disk.
Confirm.
ls -l "$BOXMAKER_HOME/broker/sessions/<id>.json"*
cat "$BOXMAKER_HOME/broker/sessions/<id>.json"
df -h "$BOXMAKER_HOME"
A good file is one line such as {"taint":"private","untrusted":false}.
Fix. For a write failure, fix space or ownership as in
audit-unavailable. For a damaged file, do not guess low: set it from the
session's results. The session's audit result records show the highest taint_after, and any
result from an untrusted grant means "untrusted":true. If unsure, write
{"taint":"secret","untrusted":true}, which is always safe. The session only loses access to
grants with a lower max_taint. A .tmp file beside it is a write that did not finish and can
be removed.
Check. The next call for the session is decided normally.
socket-forbidden
What you see. brokerd prints that a message of some kind arrived on the wrong socket, then
this entry.
Why. broker.sock accepts only tool requests from loopd; admin.sock accepts only admin
requests from bxctl. Nothing in the harness sends a wrong kind, so this is a bug or a component
doing what it should not, such as something trying to approve through the tool socket.
Confirm. Note the time, kind and socket. Check what was running: pgrep -a 'loopd|bxctl'.
Look at the session logs active at that time.
Fix. If a harness version mismatch explains it (a new bxctl against an old brokerd),
rebuild and restart both. Otherwise keep the logs, stop loopd, and investigate before running it
again.
Check. No further messages of this kind.
broker-unavailable
What you see. loopd prints that it cannot reach broker.sock, or that brokerd did not
answer in time, then this entry. Tool calls fail with "the tool broker is unavailable", the model
says so, and the conversation goes on. Or, once at startup, loopd prints "no tool broker is
configured" and this entry, and tool calls fail with those words.
Why. brokerd is not running, was restarted during a call, did not answer within [broker] timeout_ms, or loopd's [broker] socket points somewhere else or is not set. Pending approvals
are lost when brokerd restarts. bxctl audit verify lists them as pending or abandoned.
Confirm.
pgrep -a brokerd
ls -l "$BOXMAKER_HOME/run/loop-broker/"
Compare the socket path with [broker] socket in loopd's config and [sockets] broker in
brokerd.toml.
Fix. Start brokerd, or make the two paths agree. loopd needs no restart for that: it
connects per call. A changed or newly set [broker] socket does need a loopd restart.
Check. Ask the model to use a tool. The call is decided (allowed, asked or denied) instead of failing.
loopd-selftest-failed
What you see. loopd prints selftest: FAILED: <reason> and this entry, and exits 1.
Why. loopd will not start against a server that differs from the one its config describes,
that does not parse tool calls, or whose cache does not work.
Confirm and fix, by reason.
-
chat template sha256: expected …, got …,context per slot: …orslot count: …. The server changed: a model update, or new flags in~/src/nixos/hw/straylight/default.nix. Check what changed on straylight. If the change is intended, update[expect]inloopd's config:curl -s 'http://straylight:11434/props?model=ornith-1.5-35b-a3b' | jq -j .chat_template | sha256sum curl -s 'http://straylight:11434/props?model=ornith-1.5-35b-a3b' | jq '.default_generation_settings.n_ctx, .total_slots'A template change also invalidates the M0 measurements. Re-run the checks in
docs/inference-contract.mdbefore trusting the cache behaviour. -
the tool call did not come back parsed: …. The template or the server's tool parser changed. Treat it like a template change. Do not startloopduntil tool calls parse again. -
turn 2 did not reuse turn 1's cache: …. Another client may have taken the slot between the two requests. Checkcurl -s 'http://straylight:11434/slots?model=ornith-1.5-35b-a3b'and runloopd selftest --config <path>again. If it fails twice with the slot otherwise idle, the cache is not working: check the server flags forcache_promptand the KV settings. -
A connection, timeout or HTTP error. Check that
inferproxyis running and that its upstream answers:curl -s http://straylight:11434/health. A model load can take tens of seconds, so retry once.
Check. loopd selftest --config <path> prints selftest: ok.
session-log-damaged
What you see. A turn on an existing session fails with <file>:<line>: <reason> naming
sessions/<id>/<epoch>.jsonl, and this entry. Other sessions work.
Why. loopd replays the log exactly to rebuild the conversation, so a line it cannot read
would change what the model sees. It refuses rather than guess. last line does not end in a newline means a write was cut off: loopd writes a record only after the request it describes
has finished, so a cut-off line was never used.
Confirm.
f="$BOXMAKER_HOME/sessions/<id>/0.jsonl"
wc -l "$f"; tail -c 300 "$f"
Fix. Keep a copy first. Then either start a new session (always safe), or, for a cut-off last line only, drop it:
cp -a "$f" "$f.damaged-$(date +%s)"
head -n <line-1> "$f.damaged-"* > "$f" # keep the complete lines before the damaged one
Anything else (a damaged line in the middle, an unknown record type) means the file was changed
by something other than loopd. Start a new session and keep the old log for inspection.
Check. The next turn on the session succeeds. The first request re-reads the conversation if
the cache was lost, which loopd logs as a cache loss.
core-memory-unreadable
What you see. Starting a new session fails with an error naming memory/core.md, and this
entry. Existing sessions still work: they use the baseline saved when they started.
Why. memory/core.md exists but cannot be read. A missing file is fine, but starting without
memory you wrote, and without telling you, is not.
Confirm.
ls -l "$BOXMAKER_HOME/memory/core.md"; head -c 100 "$BOXMAKER_HOME/memory/core.md"
Look for wrong ownership or mode, or a file that is not UTF-8 text.
Fix. Make it readable by the user loopd runs as (chmod 0600 and chown to that user), or
save it again as UTF-8.
Check. A new session starts, and its 0.baseline.json contains the text.