0008 — A thread that asks for work gets an Operator: Smith binds, and refuses out loud
- Status: Proposed — the implementing PR (agent-smith ⚠️ VOCABULARY: the role this ADR calls the Operator is the Driver since 2026-09-10 —
operatoris the product's word for a PM company. Decision text below is unchanged; ids, binaries, trigger phrases and theOperatorTaskWorkflowtype that still sayoperatorare live invocations and deliberately lag. See ADR-0021. (Appended toStatuson purpose: this adds NO line, so every line-number citation into this file stays true.) #348) isMERGEABLE/CLEANwith both checks green (adversarialpass,lint-testpass) and is deliberately unmerged, parked on a human at the merge rung because merging it ARMS AUTONOMY: Smith gains the ability to mint a--dangerously-skip-permissions, push-authorised session without a person in the loop. Flip to Accepted when a human merges #348 — not when it goes green, and not when this ADR lands. This document is the record of the decision, not a claim that it has shipped. - Date: 2026-08-30
- Deciders: Gera (2026-08-30), recorded in the implementation at
agent_smith/workflows/reply.py:338("THE KICKOFF BINDING RULE (Gera, 2026-08-30)") and atphase_table.has_any_signal("DECISION D1 (Gera, 2026-08-30)"). The two refusals below were each settled byfable-decideand held under option-order reversal — receiptsf110c9eb2andf8077007bin~/.claude/jobs/fable/, bothverdict: RESOLVED, both with anas-givenand areversedrun. Written here by thekickoff-table-to-agentflowworker because ADR-0004 says a decision that changes how systems relate is not finished until it is written — and this one lived only in an unmerged PR and in conversation, which is the exact condition ADR-0004 exists to end.
The rule
Operator-by-default for new work. On a human turn in a Slack thread that
has no Operator bound, Smith runs the work-vs-question classifier and, on
work, mints one. The spawn stops being a judgement call.
- The trigger is a HUMAN turn, not a first turn.
reply.py:2208gates on_human_turn and status_key is not None: the turn's sender is not the webhook sender,is_cronis false, and both_sibling_hopand_self_drive_hopare 0. (Three of those four are pinned by a test that reds when the conjunct is removed;not trigger.is_cronis annotated in the source as unreachable today — the cron branch returns earlier — and kept as defence in depth. Recorded so nobody later cites it as a firing guard.) It is effectively the first turn only because the mutex below returns the incumbent on every turn after the binding one — and that is the recovery path, not an accident: a thread that missed its Operator gets one on the next human turn. - The classifier is the existing one, extended rather than duplicated:
activities/intent_classify.pyreturns aWorkDisposition(kind,slug,repo,unavailable). Only an explicit, parseablekind == "work"reaches a spawn. - Seven named dispositions come back, and every one is logged by name —
question,bound,spawned,unbound,disabled,unscoped,unavailable(types.py:ThreadOperatorResult). There is no outcome that reports as "fine" without being fine; the two refusals below say so in the thread. - The kill switch is opt-out, not opt-in (
SMITH_AUTO_OPERATOR,thread_operator.py:120). An opt-in flag would reproduce the exact complaint — a spawn that happens only when somebody remembered.
Why: the spawn was a judgement call, and judgement calls get skipped
Threads kept showing no Operator because minting one was a human decision every single time. Nothing was broken; the step was simply optional, and an optional step in a busy thread is a step that does not happen. Making the spawn automatic and gating it only on the classifier is the fix.
That reframes what the rest of this document is about. Once the spawn is automatic, the interesting engineering is no longer "when do we spawn" — it is what the machine does when it does not know, and what stops it spawning twice.
The two refusals, and why neither is a fallback
Both were settled by fable-decide under option-order reversal, because both
are cases where the comfortable answer is a default that looks like success.
An unresolvable repo REFUSES to spawn (f110c9eb2)
A thread that asks for work but names no repo Smith can resolve gets
unscoped: no session, and one honest line posted into the thread naming
the gap and the recovery.
The alternative — ship the declared single default — was rejected. An Operator
carries --dangerously-skip-permissions and push authority, so filling in an
unresolved input means minting a write-authorised session in the wrong
repository. That is agent-smith's Constitution §4 (a success-looking
default over an unresolved input) on the one input where being wrong is most
expensive. The in-repo precedent is one file over: phase_table.session_cell
refuses to render a link it could not confirm, because "an unconfirmed deep
link is a dead link, which is the one thing worse than no link"
(phase_table.py:248-249). An unconfirmed repo is that same shape with write
authority attached.
⚠️ And the backstop people reach for does not exist. "An Operator in the
wrong repo cannot merge anyway" is FALSE on this fleet: auto-merge-all.yml
merges any PR on a clean bot verdict, so a wrong-repo PR that passes CI and
review merges with nobody in the loop. Nothing downstream catches this. The
refusal has to be at the door.
Stated precisely, because it is easy to overclaim in either direction. The
default was real and its removal is on the record: commit 5694f00 on
kickoff-operator-binding drops both def _default_repo() and
"--repo", repo or _default_repo(), and its message names the finding it
answers — "address the local review — self-drive hole, silent repo
default, amnesiac Operator". You will not find that deletion in the squashed
main...HEAD diff, where activities/thread_operator.py appears as a wholly
new file; git log -S'_default_repo' on the branch is where it lives.
What landed, and what is checkable today: _resolve_repo does an EXACT lookup
against config.repo_choices() — the closed set of repos this agent may write
in AND that exist on this box, derived from the same config.writable_roots()
that claude_runner hands claude -p as --add-dir roots (§1) — and returns
"" otherwise; the launcher argv passes "--repo", repo with no fallback; and
_spawn carries assert repo, "_spawn requires a resolved checkout; see _resolve_repo". No default, and an assert where a future caller will meet it.
A classifier outage is VISIBLY degraded, never a silent question (f8077007b)
When the classifier API raises or returns nothing readable, the record carries
unavailable=True — and the workflow posts the honest line, exactly as it does
for unscoped.
The outcome still fails closed: kind stays "question", because an
outage is not evidence that a thread is work. What is illegal is failing closed
invisibly. A bare WorkDisposition(kind="question") out of an except is
byte-identical to a confident "this thread wants nothing" — §4's most-flagged
violation shape. So the outcome fails closed and the reason travels with it
in a field, rather than being inferred from an outcome that cannot carry it.
⚠️ The asymmetry is deliberate and must not be "tidied up". An ambiguous
message resolves toward work; an API failure resolves to no-spawn. These
pull opposite ways on purpose: collapsing them into "err toward work" spawns on
every outage, and collapsing them into "err toward question" reproduces the
original complaint. The distinction is not confidence, it is evidence — in
the first case the classifier ran and produced something; in the second it
never ran, and a classifier that could not run must not be credited with a
verdict. Put in terms of the action rather than the input: an unresolved repo
means the action's TARGET is unknown, so acting means acting on the wrong
thing; ambiguous intent means the target is known and only whether-to-act is
uncertain. And the failure direction has a multiplier the message case does not
— fail-toward-spawn on an API failure would mint a push-authorised session for
every message during the outage, questions included.
What the receipt actually settled, stated exactly. f8077007b was asked
whether the opposite defaults are correct or whether both unconfident outcomes
should fail the same way; it answered "keep the opposites" and held under
reversal. The visibility half — that the failure path must be annotated as
degraded rather than returning a silent question — rides in its rationale as
a §4 caveat, not as the option on the ballot. The receipt is the authority for
the asymmetry; §4 and the code comment at intent_classify.py:421 are the
authority for unavailable.
The double-spawn guard: two layers, and the AUTHORITY is layer 2
Two Operators on one thread is the two-loopers failure this fleet names everywhere else. Two mechanisms prevent it, and they are not redundant copies — they cover different scopes, and the ADR states which one is load-bearing.
Layer 1 — thread_phase.claim_task_slug, a compare-and-write mutex
src/agent_smith/thread_phase.py:382. Under the store's threading.Lock, in
one critical section: read the row, return the incumbent task_slug if there
is one, otherwise write ours and return it. Every caller learns the outcome
from the return value, and the loser threads its work under the winner.
Two things it deliberately is not:
- Not a read followed later by a write.
thread_operator_ensuremints a real session — aclaudeprocess, a tmux, a token budget — so "did this thread already get an Operator?" has to be answered atomically. - Not
record(task_slug=…).recordMERGES, so a second caller would overwrite the incumbent binding and orphan the Operator already watching the thread. (task_cli._bind_task_slugstays outside this door on purpose: an operator naming its own slug is asserting a fact, not racing for one.)
Its inverse, release_task_slug, is not optional. The claim happens BEFORE the
spawn — it must, or two turns race — so a failed spawn would otherwise leave a
slug bound to a task directory that does not exist, and every later turn would
read the binding, conclude the thread is owned, and never try again. The thread
would be permanently un-operated because of the guard that exists to operate it.
Layer 2 — start-operator's exit 3 — this is the authority
~/.local/bin/start-operator:413:
if tmux has-session -t "=$TMUX_NAME" 2>/dev/null; then
echo "refusing: tmux session '$TMUX_NAME' already exists — that is another task's Operator." >&2
exit 3
fi
This is the authority because it is a process fact, not a marker read. A
store row says what something recorded; tmux has-session says whether a
session is running right now. Any surface asking "does this task already have
an Operator?" should get its answer from exit 3.
⚠️ The = is LOAD-BEARING, and the failure it prevents has a direction worth
naming. tmux resolves an ambiguous -t by prefix, so a bare -t op-foo
reaches a live op-foo-bar. Dropping the = therefore produces false
refusals, never false permissions: task foo would be told it already has an
Operator because an unrelated task foo-bar is running.
That sounds like the safe direction, and for the spawn path it nearly is. For
the retrofit sweep of ADR-0006 it is
the dangerous one — a sweep asking exit 3 "is this task already owned?" would
read yes for a task that is genuinely unowned, and skip it forever. A guard
that fails toward "already handled" is how work goes missing quietly. Any
caller that relies on this refusal must keep the =. The launcher carries this
as a comment at the guard; it is repeated here because the next caller will be
in another repo.
The task-directory check in front of exit 3 is a cheap filter, not the guarantee. Do not cite it as one.
The honest limit — say it, so it is not inherited silently
claim_task_slug's mutex is a threading.Lock over a load-modify-save
JsonKVStore. It is within-process only. The module header already says a
cross-process write can in principle drop a row, and pr_event.py writes that
same store from a different process on the same box.
So:
| scope | what layer 1 gives you |
|---|---|
| retries, replays and racing surfaces inside the Temporal worker process | a real mutex — and every caller that can SPAWN runs in that one process, which is why the race that matters is covered |
| an out-of-process caller | nothing. Exit 3 carries the whole weight. |
The residual is real, small, and written down rather than denied by a
docstring: a dropped task_slug could un-bind a thread that has a live
Operator, and the next human turn would mint a second one — which exit 3 then
refuses.
Related, and part of the same posture: the spawn activity runs with
RetryPolicy(maximum_attempts=1). It is not the only activity on the reply
path with that policy — claude_runner carries it too, for a sibling reason
("the run opens PRs — no blind re-run") — but it is the one that says so at
the call site, and the reason is worth reading. A retry re-enters a
spawn, and a timeout retry can land while the
first attempt's launcher is still mid-spawn — before it has written anything
the mutex can see. One attempt; the next human turn is the recovery.
Long-lived-workflow safety, and the control that is the interesting part
SmithReplyWorkflow is one of the long-running classes, so the change rides a
patch gate: _AUTO_OPERATOR_PATCH_ID = "smith-kickoff-binds-operator-v1"
(reply.py:357), read unconditionally at both call sites (never behind a
short-circuiting and).
It was proved against five real pre-change Temporal Cloud histories, and the control is worth more than the result:
With the gate removed, only TWO of the five stranded. The other three were GitHub-webhook turns that never reach the branch at all — they replay green under the broken code too. They were vacuous instruments.
That is the failure this ADR most wants on the record, because a green replay across five specimens would have read as strong evidence and three-fifths of it measured nothing. It is the same defect ADR-0006 catalogues twice (presence checked in place of substance), reached from a different direction.
The committed specimen is tests/fixtures/reply_pre_kickoff_history.json — the
complete 84-event history of smith-reply-C0BDW7G1Z62-1788062812.367139 (run
01a050d9-50ec-7090-94c2-ecddcffb0999), fetched from Temporal Cloud on
2026-08-30, a real human turn that closed hours before the rule was written and
with the gate still undeployed. The test file asserts the properties that make
it able to fail rather than assuming them:
test_the_capture_really_is_pre_kickoff (the bytes carry neither the patch
marker nor the new activity) and
test_the_specimen_is_a_real_instrument (a human sender, and a run that
actually reached the branch). Positive control, run by hand on 2026-08-30 with
the patch id removed from both call sites: that specimen strands. Gated, it is
clean. The error is worth quoting in full, because it does not name the gate
that was removed:
[TMPRL1100] Nondeterminism error: Non-deprecated patch marker encountered
for change smith-task-watch-v1, but there is no corresponding change command!
It strands on a downstream patch id — removing one gate shifts every
command index after it, so the mismatch surfaces at the next marker. Anyone
debugging a future [TMPRL1100] here should read the named change as where
the histories diverged by, not what broke.
⚠️ How much of that measurement is reproducible, so nobody inherits it as
more than it is. Only the ONE specimen above is committed; the other four
were fetched, replayed and discarded, so "five histories, two stranded" is
attested by the test module's own header and by nothing a later reader can
re-run. The conclusion is what survives independently, because the committed
test enforces it: a specimen must be a human turn that reaches the branch, or
test_the_specimen_is_a_real_instrument reds. The count is history; the
property is a gate.
The general rule for this repo, restated because #274 cost every live
OperatorTaskWorkflow a restart: a fresh workflow records the new commands
and can never fail. Only a pre-change history is an instrument — and only one
whose turn actually reaches the changed branch.
Decision D1 — stated correctly, because it is easy to state wrongly
It is tempting to say "the question branch posts no table". That is the observed behaviour and the wrong description of the mechanism.
D1 is derived from the ROWS, not from the classifier verdict.
phase_table.has_any_signal(rows) is false only when EVERY row is a gap
(PENDING or NO_SIGNAL); when it is false the Slack message is withheld
(the banner is at activities/thread_phase.py:99; the gate itself is
if not input.since and publishable: at :113). The complaint that produced it
was a real
post — eleven rows, every one an em dash, in a thread that had asked a
question.
It is deliberately not a second rule about task bindings (§1), and the case
that forced it is concrete: a pr_drive thread carries real PR / review /
merged evidence and frequently has no bound Operator task, and its table
is exactly the one people read. It has signal, so it publishes.
And D1 suppresses only the message. The render still happens and is still
returned — states / done / pr_url are what workflows branch on, and a
thread's machine half must not go dark because its human half had nothing to
say yet.
So a question thread posts no table because its rows are all gaps, which coincides with the classifier's verdict without being caused by it. Two mechanisms, one visible outcome; a future change to one does not silently move the other.
Relation to the neighbouring ADRs
- ADR-0001 — The Architect is
an enforced observe+spawn singleton. Read carelessly, this ADR looks like a
contradiction of it, and it is not. ADR-0001 makes the Architect a
singleton — one Architect, whose one active power is minting Operators. The
clause that governs a NEW spawner is its item 2, which forbids bypassing the
launcher and quotes
start-operator's own header for the reason. That header reads: "EVERY DOOR THAT EXISTS LANDS HERE… One codepath, so 'how work starts' cannot drift into several answers." tmux calls it directly;/agentsPOSTs/api/intakeand the Supervisor calls it. So the invariant is one CODEPATH, not one caller. (That quote isstart-operator's header as it reads today, verified directly at~/.local/bin/start-operator:60-62. ADR-0001 quotes an earlier revision of the same lines — "EVERY DOOR LANDS HERE… cannot drift into three answers" — which was accurate when it was written and has since been reworded upstream. Two quotes of one moving source; the file is the authority, not either ADR.) Smith becomes the third door, and it lands on that codepath — the launcher records which one instartedFrom("smith","tmux","agents"), a field that exists precisely because doors are expected to multiply while the codepath does not. This is worth stating because that door was advertised before it existed: the launcher's header asserted "Agent Smith calls it from its reply workflow" until 2026-08-28, when it was corrected to ⚠️ "THE SMITH DOOR IS NOT BUILT… there is no invocation of this script anywhere in agent-smith/src." The consequence named there is the same complaint this ADR opens with, from the other side: a substantive ask handled in a reply turn has nowhere to become an Operator task, so that work dies leaving no record. #348 builds the door the launcher had already reserved. What ADR-0001 forbids — a session started by hand, with notask.json, no criterion, no STATE.md, no board row and no Supervisor respawn — this rule does not do, and cannot, because it goes through the launcher. - ADR-0006 — One session-state
classifier: six states, a wake path, and
nullfor everything else (agentflow-relay PR #82, open at the time of writing; the document's own status line readsAccepted). The two halves of one problem: this binding rule stops NEW work arriving unowned;needsOwnerenumerates what predates the rule, for a retrofit sweep. Neither is sufficient alone — a binding rule does not reach backwards, and a worklist refilled from the front never empties. ⚠️needsOwneris advisory.needsOwnerForOR's in a statusline marker, so a bound Operator whose statusline still renders○ no operatorreportstrue. That is deliberate — the alternative would hide a task file naming an Operator that no longer exists — but it meanstrueis not proof of absence. The correct re-verification before a retrofit sweep spawns anything is this ADR's exit-3 call, which is a process fact, not the flag and not a reimplemented conjunction. ADR-0006 states that exit-3 rule inline and asks, in the same paragraph, that whoever holds the binding rule "should land it as ADR-0008 and repoint this paragraph." This is that document; the repoint is a one-line follow-up on #82, not a claim this ADR can make on its behalf. - ADR-0007 — The phase
table crosses by snapshot: Smith derives, the board displays (Accepted).
Sibling work from the same effort (agent-smith #348 → agentflow-relay #83 →
propflowai #6644). The relationship is specific: ADR-0007 makes a task's
phases visible on
/agents, and the binding rule is what guarantees there is a task to link to — it is why the board link in a kickoff post is never dead. ADR-0007 does not contain this rule; an earlier revision of ADR-0006 cited it as if it did, and that mis-citation is what produced this document. - ADR-0003 — the spawn is a rung integrating by a launcher invocation on the same box, not a call across the ladder. Nothing on a request path anywhere reaches into Smith to ask for an Operator, and nothing should.
What a future change must not do
- Must not make either refusal silent.
unscopedandunavailableboth end with a thread that asked for work and got no Operator — which is precisely the state this rule exists to remove. Staying quiet about it would reproduce the complaint while calling it a safety feature. - Must not collapse the
unavailable/questiondistinction, in either direction. Both mean "no Operator"; only one of them is a judgement. - Must not restore a repo default — not a config default, not "the repo the
last task used", not
or PROPFLOWAI_DIR. Write authority over an unresolved input is the thing being refused. - Must not drop the
=fromtmux has-session -t "=$TMUX_NAME". Prefix matching turns the authority into a coin flip. - Must not treat
needsOwner, a task-directory check, or a store row as the double-spawn authority. Exit 3 is the authority. - Must not verify a patch gate on a fresh workflow, and must not count a green replay of a specimen that never reaches the changed branch. Prove the instrument can fail before believing it passed.
- Must not make the kill switch opt-in. That is the original complaint with an environment variable in front of it.
Consequences
- New work arrives owned. A human asking Smith for work in a thread gets an Operator without anyone remembering to ask for one, and the thread's phase table (ADR-0007) has a real task to describe from its first render.
- Smith gains the ability to mint push-authorised sessions unattended. That
is the reason this ADR is
Proposedand #348 is parked on a human. The blast radius is bounded three ways: the opt-out kill switch, a non-blockingBoundedSemaphore(2)spawn cap (a thread that cannot get a slot returnsunboundimmediately rather than parking a thread of the 20-slot interactive activity pool), andmaximum_attempts=1. - "Does this thread have an Operator?" now has one authoritative answer —
start-operatorexit 3 — and every other signal in the fleet is explicitly advisory relative to it. That is the cross-system half of this decision, and it is why this belongs in the ledger rather than in agent-smith'sCLAUDE.md. - The retrofit sweep in ADR-0006 has a stopping condition. With NEW work
arriving owned,
needsOwnerbecomes a finite backlog instead of a queue refilled from the front.