0008 — A thread that asks for work gets an Operator: Smith binds, and refuses out loud

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.

  1. The trigger is a HUMAN turn, not a first turn. reply.py:2208 gates on _human_turn and status_key is not None: the turn's sender is not the webhook sender, is_cron is false, and both _sibling_hop and _self_drive_hop are 0. (Three of those four are pinned by a test that reds when the conjunct is removed; not trigger.is_cron is 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.
  2. The classifier is the existing one, extended rather than duplicated: activities/intent_classify.py returns a WorkDisposition (kind, slug, repo, unavailable). Only an explicit, parseable kind == "work" reaches a spawn.
  3. Seven named dispositions come back, and every one is logged by namequestion, 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.
  4. 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:

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

What a future change must not do

Consequences