feat!: headless is the only runner - delete the tmux run path (phase 4)

Agent runs are now always worker-owned 'claude -p' subprocesses; tmux
survives only for the interactive /login flow.

- deleted: worker.capture_agent_output/_pane_tail + the capture loop
  arm (the empty-/log bug's home), spawn's tmux launch/_claude_command,
  the tmux resume/kill branches (the silent-send-keys bug's home),
  tmux.session_name/list_sessions, the CLI attach subcommand, the
  'runner' setting
- spawn: task is now a hard requirement (headless has no idle REPL) -
  enforced in spawn (SpawnError) and the API (400); onboarding seeding
  dropped (-p skips the trust dialog)
- resume: single headless path; pre-headless agent rows (no session_id)
  degrade to the context-re-injection fresh run
- settings_gen: permissions allowlist is always emitted
- credsync: change-triggered uploads key on .claude/.credentials.json
  only (claude touches ~/.claude.json every run - keying on it would
  ping-pong uploads between workers); logins still publish explicitly
- cli list: liveness from agent_runs in the DB, not tmux
- tests: spawn/kill/resume re-pointed at the fake_launch seam
  (conftest); integration test now drives API -> worker -> real fake
  claude subprocess -> events endpoint; README documents the headless
  model + multi-worker deployment invariants

Suite 295 green; frontend unchanged since phase 3.
This commit is contained in:
2026-07-21 23:20:39 -04:00
parent 6c2e73d4ec
commit 1517e4dca8
17 changed files with 337 additions and 354 deletions
+39 -12
View File
@@ -13,10 +13,11 @@ git remote, and your own network exposure.
> control layer, HTTP API, database, migrations, and verification/approval hooks are
> implemented and tested (106 tests, SQLite). Phase 2 adds credential resolution +
> injection, role-based forge-workflow skills, a hard approval gate, and a CI-status
> poller. Live end-to-end agent spawning against a real `claude` binary + tmux is stubbed
> behind mockable seams (`tmux`, `verify`, `forge`, `gitops`, `spawn.resume`) and wired
> but not yet exercised against production binaries. See [`docs/PLAN.md`](docs/PLAN.md)
> for the full design and roadmap.
> poller. Agent runs are headless (`claude -p --output-format stream-json`, supervised
> by the worker, events persisted to the DB); the run/kill/resume paths are exercised
> end-to-end against a scripted fake claude binary, with a manual validation script
> (`scripts/validate_claude_headless.sh`) for the real one. See
> [`docs/PLAN.md`](docs/PLAN.md) for the full design and roadmap.
---
@@ -45,19 +46,23 @@ the control layer and API are disposable compute that can restart or scale out f
```
writes reads (+ answer backfill)
┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐
control layer │───────▶│ database │◀───────│ HTTP API │
worker(s) │───────▶│ database │◀───────│ HTTP API │
│ (CLI + hooks) │ │ PG / SQLite │ │ (FastAPI) │
└──────────────────┘ └──────────────┘ └──────────────────┘
│ ▲ ▲
│ spawns │ Stop / PreToolUse / Notification hooks │ curl, UI, any client
▼ │ write checkmark + log rows │ (bearer token)
tmux + claude binary (one working dir / worktree per agent)
▼ │ + streamed run events, checkmark, log │ (bearer token)
claude -p --output-format stream-json (one working dir / worktree per agent)
```
- **Control layer** (`handler.control`) — the only writer. Spawns/lists/attaches/kills
agents as `tmux` sessions running the `claude` binary, one working directory or git
worktree per agent, namespaced `project__agent`. Stateless; every write goes straight
to the database.
- **Control layer / workers** (`handler.control`) — the only writer. Runs each agent as
a **headless** `claude -p --output-format stream-json` subprocess (one working
directory or git worktree per agent), streams every stdout event into the database as
it happens, and reconciles agent status from the process itself (exit code + EOF —
positive liveness, no screen scraping). Stateless: repo state is pulled from git when
a task is claimed, claude session transcripts are archived to / materialized from the
DB for cross-worker `--resume`, and the claude login credential bundle is distributed
encrypted through the DB. tmux survives only to drive the interactive `/login` flow.
- **Hooks** (`handler.hooks`) — run inside each agent via a generated `settings.json`.
They write the checkpoint/log rows and enforce the test and push gates.
- **API** (`handler.api`) — a thin, read-mostly HTTP layer over the same database (the
@@ -77,10 +82,32 @@ The data model is defined once (SQLAlchemy Core) and renders correctly on both:
Portable column types bridge the two, and the checkmark upsert uses native
`INSERT … ON CONFLICT DO UPDATE` on both dialects. Migrations are Alembic, dual-dialect.
### Scaling workers horizontally
Multiple worker containers can drain the same command queue concurrently (Postgres
`FOR UPDATE SKIP LOCKED`); each supervises up to `MAX_CONCURRENT_RUNS` claude processes
and skips claiming run-starting commands while full, leaving them for a less-loaded
worker. Workers heartbeat into the DB; if one dies mid-run, any surviving worker's
reaper marks its runs (and their agents) `crashed` — visible in the UI with the last
output preserved — and the operator resumes explicitly on whichever worker picks it up.
Deployment invariants for multi-worker:
- **No shared filesystems.** Git carries repo state (workers clone/pull on claim);
claude session transcripts live in `session_archives`; login credentials are
Fernet-encrypted into `runtime_secrets` and materialized by every worker.
- **Identical `PROJECTS_ROOT` on every worker** — claude keys its session storage to
the absolute working-dir path, so cross-worker `--resume` needs the same layout.
- **The same `HANDLER_SECRET_KEY` on every worker** (and the API) — without it, the
credential bundle can't be distributed and only the worker that ran `/login` can run
agents.
- The two-step web login is automatically pinned to one worker
(`commands.target_worker`), so it works unchanged with a fleet.
## Requirements
- Python 3.11+
- `git` and `tmux` (for live spawning)
- `git` (for live spawning) and `tmux` (only for the web `/login` flow)
- A `claude` binary, authenticated (for live spawning)
- `mise` in each managed project, with a `.mise.toml` defining at least a `test` task
- Postgres (default) — or nothing but a file path for the SQLite fallback