0053 — While a relay owns Gera's line the brain stands down, and the run it would have answered in still starts
- Status: Accepted, 2026-10-14. Written at decision time per ADR-0004, with the implementing PR (agent-smith#1033) open and unmerged — the decision is recorded before the mechanism lands, not after.
- Date: 2026-10-14
- Deciders:
- The requirement is Gera's. Slack DM, 2026-10-12: Smith is the messenger — "Gera talks to Smith in the Slack thread, Smith forwards each message to the session, and posts the session's updates back." One message to one place is the whole of what "messenger" means, and he ranked this behaviour first of the five.
- Where the gate lives, and that it must not skip the dispatch, is the
lane's ruling — made from a code trace on 2026-10-14 and reported to the
gera-tabs-slack-relayDriver, who approved it before any code was written. Gera has not seen this distinction and does not need to; it is an engineering consequence of his requirement, and this ADR records it so it can be overruled rather than rediscovered.
- Systems:
work-on-n(the relay),smith-reply(the brain) — agent-smithsrc/agent_smith/dispatcher.py,src/agent_smith/workflows/reply.py,src/agent_smith/activities/gera_messenger.py,src/agent_smith/gera_messenger.py,src/agent_smith/types.py. - Supersedes / amends: nothing. It sits under ADR-0051, which gives a human's thread one voice; this says which of two live systems that voice is speaking for on a given line.
The problem
Two systems both answer the same message.
With work on 1 active, Gera types continue. The relay forwards it into
session 1 — that is the feature — and the brain answers it as well, because
nothing tells the brain that the line was not addressed to it. He gets two
replies to one message, and the brain's is the more authoritative-looking of
the two while being about something else entirely.
This is invisible until both halves exist. It became reachable on 2026-10-14 at 03:38Z, when the door (#1012) merged and the worker restarted 40 s later.
The decision
1. While a relay is active for Gera's conversation, a line the relay will CARRY is relay input and not a prompt. The brain does not answer it.
Exactly one bucket stands the brain down — the line the relay forwards. Every other shape is still Smith's and still reaches him:
| the line | where gera_messenger.plan files it |
who answers |
|---|---|---|
| a plain line | forward |
the relay only |
one of Smith's own doors (give me a summary on 2) |
for_smith |
the brain |
| anything typed after the stop word | after_stop |
the brain |
a /-leading line, which the send refuses outright |
refused |
the brain |
| the stop word itself | stopped_by |
the brain |
That table is not a courtesy. Plan.for_smith and Plan.after_stop existed
with no reader anywhere in src/, and were harmless only because the brain
answered everything. The moment the brain stands down, a field nothing reads is
a line nothing answers. The stand-down not firing for those buckets is their
consumer.
2. ⚠️ The stand-down does NOT skip the dispatch. The run still starts; only its first turn is skipped.
This is the part worth an ADR, because the obvious reading of decision 1 is a silent data-loss bug and anyone implementing it will reach for that reading first:
- the relay posts nothing itself (ADR-0051);
- it deposits the session's words and signals
add_contexton the thread's owner; - the owner is
dispatcher.workflow_id_for(channel, newest_root), and_newest_rootdeliberately follows Gera's latest message — in a DM every top-level line is its own thread root; - so the owner is the reply workflow started by the very line a "don't dispatch it" implementation would have refused to start.
No run, no owner, the signal fails into a logged warning, and the session's reply reaches nobody. Gera would go from two answers to none — quietly, because the dispatcher writes the inbound conversation-log row before it starts anything, so the relay still sees his line and still believes it delivered.
3. The decision is made at the dispatcher, because it is the only door that can make it. Two reasons, and the first is the binding one:
- The predicate has to separate a line Gera typed from the relay's own
wake. The wake arrives as an
add_contextsignal carrying the session's words, which look forward-able to any text-only predicate — a check further in would silence the session instead of Smith's duplicate of it. A signal never passes throughdispatch; a typed message always does. - The check is file I/O (the mode store), which inside the reply workflow would
have to be an activity, which is a new command in a long-lived class, which
owes a
workflow.patched()gate and a replay proof. At the dispatcher it is an ordinary function call.
4. It is a predicate over the BATCH, not over the line. plan cuts at the
stop word. Gera says stop, then thanks five seconds later; the 20 s tick has
not run, so the mode is still live and thanks is a plain word on its own. A
per-line predicate stands down on it — and the tick, planning the batch, files
it under after_stop and never forwards it. Nobody answers it. So the
dispatcher reads the same rows from the same baseline through the same filter
and asks the same classifier.
One classifier, two callers. If the dispatcher's copy ever drifts from the tick's, the line they disagree about is the line nobody answers.
5. Every uncertainty leaves the line to the brain. An unreadable mode store, no mode, a sender who is not Gera, a log row that did not land, a Slack outage on the bot uid — all of them mean "the brain answers". A reply Gera did not need is visible and cheap. Silence nobody is told about is the failure class this whole module is built to refuse.
What was rejected
- "Don't dispatch a line the relay is carrying." The simplest reading of decision 1, and it breaks the relay's only delivery path — see decision 2. It is now a named mutation on a test rather than a paragraph, because a paragraph does not survive the next person who sees an obvious optimization.
- A sentence in
prompts.pytelling the brain to stay quiet. A prompt instruction is not a mechanism: it fails open into exactly the defect it is meant to prevent, with nothing to measure.prompts.pywas taught thework on Ndoor by #1012 and is deliberately left alone here. - An
abstain-style turn that runs the brain and posts nothing. It costs a full claude run per relayed line and still depends on the model complying. - Deciding inside
reply.py's workflow body. Cannot tell Gera's line from the relay's wake (decision 3), and owes a patch gate it does not need to.
Consequences
- One message gets one answer, and the answer comes from the session Gera thinks he is talking to.
- The reply workflow for a relayed line starts, posts nothing of its own, idles in its follow-up loop, and is there to carry the relay's update when it arrives. It costs one workflow start per relayed line and no claude run.
- No
workflow.patched()gate is owed. The decision rides a defaulted field onSmithTrigger; what would owe a gate is a command that branches on something an OLD run can evaluate differently, and an old run deserializesFalseand emits exactly the commands it recorded. Measured rather than argued: the two real pre-change histories intests/fixtures/replay unchanged, with a negative control that strands them when the branch is forced on. - This does not repair the hand-off it depends on. The relay's deposit
reaching Gera at all is already unreliable and is open against decision
b91976949(raised 2026-10-14 on the decider'sNEEDS HUMANreceiptf4e35edfd; this ADR previously citedb91874375, which was about numbering anchors, disclaimed being a blocker, and is withdrawn). The stand-down removes a duplicate answer; it does not make the remaining one arrive. Anyone reading this ADR as "the relay now works" is reading it wrong.