Design: one runner — every dispatch is a tmux delivery or a tmux (re)spawn
Phase 2 of 3. Derived from
bugfix.md(locked). Human approval for this tier-3 change happens at the PR.
Architecture
Today the dispatcher is a two-runner switchyard: RoutingConfig.runner picks the runner at first spawn, and Session.runner — a per-record copy of that choice — picks it on every later dispatch. The bug lives in the copy: a record whose runner says "process" (the silent dataclass default) reroutes deliveries to a headless subprocess forever.
The design deletes the switchyard. After this change the dispatch plane has exactly one shape:
flowchart TD
EV[routed event] --> M{registry has a\nlive session?}
M -- no, spawn armed --> S[tmux.spawn loop-slug\nnew conversation]
M -- yes --> D[tmux.deliver into\nsession.tmuxTarget]
D -- ok --> DONE[dispatch.succeeded]
D -- "missing / dead pane\n(also: legacy record with\nno tmuxTarget)" --> R[respawn path\nissue-80/89/146]
R -- resumable id --> RES[tmux.spawn --resume\nsame conversation]
R -- else --> S
S --> DONE
RES --> DONEEverything on that graph already exists (issues 32, 80, 86, 89, 146); the change removes the second, invisible plane beside it. Legacy "process" records need no migration: they have no tmuxTarget, so their next event reads as "no live tmux session" and heals through the respawn path — resuming the recorded conversation id when Claude Code can, exactly as a crashed tmux session would.
Components & interfaces
the_loop/webhook/dispatcher.py
RoutingConfig: therunnerfield is removed.from_mappingwarns once per load when the mapping still carries arunnerkey with any value other than"tmux":routing.runner was removed; sessions are always hosted in tmux. ("tmux"is accepted silently — it states the only behaviour there is.)Dispatcher._dispatch_one: thesession.runner == "tmux"branch becomes the only body. Theadapter.resume(...)else-branch, theno-adapterdrop for it, and_log_usage(headless-only telemetry — tmux TUIs report no JSON usage) are removed. A session with an emptytmux_targetis handled byTmuxRunner.deliverreportingsession_missing, which routes into the existing_respawn_tmux.Dispatcher._spawn_for: theconfig.runner == "tmux"guard and the whole headlessadapter.spawn(...)tail are removed;_spawn_tmuxis the only spawn.graphlink.on_spawn(..., runner="tmux")call sites are unchanged.- The constructor comment about "a registry may hold tmux-mode sessions even when config.runner is process" is rewritten: the tmux runner is simply the dispatcher's runner.
InteractionConfig.from_mappingloses itsrunnerparameter and the cli-under-process warning: the combination it warned about no longer exists.
the_loop/harness/base.py (+ claude_code.py, cursor_agent.py)
- Removed:
HarnessAdapter.resume,HarnessAdapter.spawn,_run,_resume_argv(base and both adapters),DispatchResult,_session_id_from_output,_SESSION_ID_KEYS. - Kept, renamed for honesty:
_spawn_argv→_oneshot_argv, still the implementation behindoneshot_argv— the critic-review invocation surface (issue-108) is explicitly out of the removal's scope (AC2.1), as areUsage,usage_from_outputandparse_json_object, which critics import. - Kept:
interactive_argv/interactive_resume_argv(the tmux surface) andprepare_environment(issue-90/143). harness/__init__.pystops exportingDispatchResult.
the_loop/sessions/registry.py
Session.runneris removed from the dataclass,to_dictandfrom_dict(an old record's stored value is ignored on read —from_dictsimply stops looking at the key).tmux_targetstays;""now means "no tmux session has been spawned for this record yet" (legacy records, andsessions registerself-registrations, which get their tmux session on first dispatch via the respawn path).- The
session.registeredevent stops carrying arunnerfield.
the_loop/runner.py
check_dependencies(runner, web_enabled)→check_dependencies(web_enabled): tmux is always required; ttyd additionally when the web terminal is enabled.TmuxRunner.delivertreats an empty target assession_missing(today an empty string would be handed totmux has-session -t '') — the seam that makes AC2.4's lazy healing work.
Commands (gh_webhook.py, poll.py, sessions_cmd.py)
- Startup/hot-reload log lines stop printing
runner=…. sessions attach: therunner != "tmux"refusal becomes an empty-tmux_targetmessage ("no tmux session recorded yet for this work item; one is spawned on its next dispatched event").sessions list: theRunnercolumn is dropped (theTmuxcolumn already carries the target); JSON output loses therunnerkey with the record field.sessions start's success line prints the tmux target instead ofrunner=….announce.SessionAnnouncer.announce: thesession.runner != "tmux"guard reduces tonot session.tmux_target.
Config schema + templates
.the-loop/cli-config.schema.json: therouting.runnerproperty is removed; prose in neighbouring descriptions that referenced the process runner is updated..the-loop/cli-config.yaml(this repo's own config) andskills/the-loop/templates/cli-config.yaml(the template/the-loop:initscaffolds): therunner:key and process-runner commentary are removed.
Data models
Session record, after:
{
"workItem": {"ref": "github:owner/repo#15", "...": "..."},
"harness": "claude",
"harnessSessionId": "<uuid4>",
"cwd": "/path/to/checkout",
"status": "active",
"createdAt": "…", "lastEventAt": "…",
"tmuxTarget": "loop-owner-repo-15",
"recentDeliveries": ["…"]
}The only removal is runner. Old records that still carry it (any value) parse identically; the key is simply not read.
Error handling
- tmux missing at daemon start:
check_dependenciesnow always names tmux;gh-webhook start/poll startrefuse to start, with install hints — previously only whenrunner: tmuxwas configured. - tmux missing at dispatch: unchanged —
TmuxRunner._runreports it, the dispatch fails loudly and the delivery is released for retry. - Legacy record, no
tmuxTarget:deliverreportssession_missing;_respawn_tmuxre-derivesloop-<slug>viatarget_for, tries an interactive resume of the recorded conversation, falls back to fresh — all pre-existing, tested behaviour (issues 80/89/146). routing.runnerstill configured: ignored with a warning naming the removal; never an error, so an un-edited config cannot brick a daemon.- cursor as
defaultHarness:_spawn_tmuxsurfacesUnsupportedRunnerErrorfrominteractive_argvas a failed spawn — the same behaviour tmux mode has today; it is now the only behaviour, and the schema description fordefaultHarnesssays so.
Security design
- The trust boundary from the requirements is enforced unchanged: untrusted payload data reaches prompts only (rendered under the "UNTRUSTED" banner), never argv/paths/targets.
_SESSION_ID_REstill gates recorded session ids before they enter a resume argv;_LOOP_TARGET_REstill gates whatterminate_harnessmay signal. - The abuse case from the requirements (a doctored record's
cwdsteering a silent headless resume) is closed structurally: the silent execution path no longer exists, and the loud path announces itself (named tmux session, events, first-spawn ticket comment). - Fail-closed posture: no tmux → no daemon / failed dispatch; never a fallback to invisible execution.
Testing strategy
TDD per task (red → green recorded in the execution log). The stub/fake adapters and stateful stub tmux from cli/tests are reused.
- Unit:
RoutingConfig.from_mappingignore-and-warn;Sessionround-trip withoutrunner(and reading a legacy record that has it);check_dependenciesalways requiring tmux; adapter surface —oneshot_argvintact, noresume/spawn/DispatchResult;deliveron an empty target reportingsession_missing. - Integration (Gherkin docstrings, linked to AC): an event for a legacy
runner: "process"record (notmuxTarget) is delivered by respawning a tmux session that resumes the recorded conversation (AC2.4 — the reported bug's scenario, now loud); an unmatched armed event spawns a tmux session with norouting.runnerconfigured (AC1.1); existing tmux delivery/respawn suites keep passing unmodified in behaviour. - Removed tests: process-runner dispatch/spawn suites (headless
adapter.resume/adapter.spawnpaths, runner-selection config tests, the cli-mode-under-process interaction warning) go with the code they pinned. - Full gate: pytest, ruff (check + format), pyright, markdownlint,
validate_config.py— the same commands the hooks and CI run.
Trade-offs & decisions
- Remove, don't reconcile. The issue offered a warning/doctor/docs fix; the owner chose removal. One runner means the class of config/record disagreement cannot recur — smaller surface beats better diagnostics on a surface nobody wants.
- Rename
_spawn_argv→_oneshot_argvrather than delete. Critic reviews (issue-108) run harnesses one-shot by design; that is an explicit non-goal of this removal. The rename keeps the method from advertising a spawn path that no longer exists. - Lazy healing over migration. No eager registry rewrite: records heal on their next event through code that already exists and is already tested (issue-146 hardened it). A migration tool would be new code to review for a one-time effect.
- Warn, never fail, on a leftover
routing.runner. A daemon that refuses to start over a removed key punishes the upgrade; a warning names the new reality and moves on. - Keep the
session.registered/session.spawnedevent shape otherwise intact (runner/viafields dropped or fixed to"tmux"where consumers expect a value) — the event log is observability, not API; the docs list the field change.
Open questions
- None for the reviewer beyond the PR itself; the scope decision is the owner's ticket comment.