mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 21:16:25 +00:00
1517e4dca8
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.
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
"""Control-layer spawn: the hard test-task gate, settings generation, identity env.
|
|
|
|
Spawns go through the ``fake_launch`` seam (conftest) — the headless analogue of the old
|
|
fake tmux: it records the launch and mirrors its DB side effects, no subprocess."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from handler.control import spawn
|
|
from handler.db import repository as repo
|
|
from handler.db.engine import get_engine
|
|
|
|
|
|
def _register_project(root):
|
|
with get_engine().begin() as conn:
|
|
repo.create_project(conn, "proj", str(root))
|
|
|
|
|
|
def _write_mise(root, with_test=True):
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
body = "[tasks.lint]\nrun = 'ruff check .'\n"
|
|
if with_test:
|
|
body = "[tasks.test]\nrun = 'pytest'\n" + body
|
|
(root / ".mise.toml").write_text(body)
|
|
|
|
|
|
def test_spawn_refuses_without_test_task(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=False)
|
|
_register_project(root)
|
|
with pytest.raises(spawn.SpawnError, match="no \\[tasks.test\\]"):
|
|
spawn.spawn("proj", "api", task="do it")
|
|
assert fake_launch == []
|
|
|
|
|
|
def test_spawn_refuses_without_mise_file(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
_register_project(root)
|
|
with pytest.raises(spawn.SpawnError, match="no mise config"):
|
|
spawn.spawn("proj", "api", task="do it")
|
|
|
|
|
|
def test_spawn_refuses_without_task(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root)
|
|
_register_project(root)
|
|
with pytest.raises(spawn.SpawnError, match="requires a task"):
|
|
spawn.spawn("proj", "api")
|
|
# Fail-fast: no orphaned agent row behind the refused spawn.
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_agent_by_name(conn, "proj", "api") is None
|
|
assert fake_launch == []
|
|
|
|
|
|
def test_spawn_accepts_dotless_mise_toml(env, fake_launch):
|
|
# mise also reads `mise.toml` (no leading dot); the gate must honor it too.
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
(root / "mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn("proj", "api", task="do it")
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
|
|
|
|
|
|
def test_spawn_creates_agent_settings_and_run(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn("proj", "api", task="build the thing")
|
|
|
|
# Agent row created.
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
|
|
|
|
# settings.json wires all four hook events AND the headless permission allowlist
|
|
# (claude -p auto-denies anything that would prompt; the allowlist is what lets
|
|
# normal work proceed — the hooks stay the hard gate).
|
|
settings = json.loads((root / ".claude" / "settings.json").read_text())
|
|
assert set(settings["hooks"]) == {"Stop", "SessionEnd", "PreToolUse", "Notification"}
|
|
pre = settings["hooks"]["PreToolUse"][0]
|
|
assert pre["matcher"] == "AskUserQuestion|Bash"
|
|
assert "handler.hooks pre_tool_use" in pre["hooks"][0]["command"]
|
|
assert settings["permissions"]["defaultMode"] == "acceptEdits"
|
|
assert "Bash(git *)" in settings["permissions"]["allow"]
|
|
|
|
# A headless run launched with identity + DATABASE_URL in env and the task as prompt.
|
|
call = fake_launch[0]
|
|
assert call["kind"] == "spawn"
|
|
assert call["prompt"] == "build the thing"
|
|
assert call["env"]["HANDLER_PROJECT_ID"] == "proj"
|
|
assert call["env"]["HANDLER_AGENT_NAME"] == "api"
|
|
assert call["env"]["HANDLER_AGENT_ID"] == str(agent["id"])
|
|
assert call["env"]["DATABASE_URL"] == env["url"]
|
|
# The run row + session id landed on the agent.
|
|
with get_engine().begin() as conn:
|
|
row = repo.get_agent_by_name(conn, "proj", "api")
|
|
assert row["session_id"] == call["run"]["session_id"]
|
|
assert repo.get_latest_run(conn, row["id"])["kind"] == "spawn"
|
|
|
|
|
|
def test_spawn_mise_init_skips_test_gate_and_marks_env(env, fake_launch):
|
|
# A repo with no .mise.toml at all: the normal gate would refuse, but the mise-init
|
|
# bootstrap agent must launch anyway (creating that file is its whole job).
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn(
|
|
"proj", "mise-init", task="write the mise config", require_tests=False, mise_init=True
|
|
)
|
|
|
|
with get_engine().begin() as conn:
|
|
assert repo.get_agent_by_name(conn, "proj", "mise-init")["id"] == agent["id"]
|
|
# The launched run carries HANDLER_MISE_INIT so its hooks enforce commit + push.
|
|
assert fake_launch[0]["env"]["HANDLER_MISE_INIT"] == "1"
|
|
|
|
|
|
def test_spawn_still_gates_without_mise_init_flag(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
_register_project(root)
|
|
# require_tests defaults on, so a normal spawn against a mise-less repo still refuses.
|
|
with pytest.raises(spawn.SpawnError, match="no mise config"):
|
|
spawn.spawn("proj", "api", task="do it")
|
|
assert fake_launch == []
|
|
|
|
|
|
def test_kill_cancels_run_and_sets_done(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
spawn.spawn("proj", "api", task="do it")
|
|
|
|
spawn.kill("proj", "api")
|
|
with get_engine().begin() as conn:
|
|
agent = repo.get_agent_by_name(conn, "proj", "api")
|
|
assert agent["status"] == "done"
|
|
# The running run was flagged; the owning supervisor terminates its own child.
|
|
assert repo.get_latest_run(conn, agent["id"])["cancel_requested"] is True
|
|
|
|
|
|
def test_resume_reinjects_when_no_transcript(env, fake_launch):
|
|
"""A resume with no archive and no local transcript degrades to a fresh run whose
|
|
prompt carries the operator's answer (context re-injection)."""
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
spawn.spawn("proj", "api", task="do it")
|
|
with get_engine().begin() as conn:
|
|
agent = repo.get_agent_by_name(conn, "proj", "api")
|
|
repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed")
|
|
|
|
ok, detail = spawn.resume(agent, "use Postgres")
|
|
assert ok is True
|
|
assert "re-injected" in detail
|
|
assert fake_launch[-1]["kind"] == "spawn"
|
|
assert "use Postgres" in fake_launch[-1]["prompt"]
|
|
|
|
|
|
def test_resume_refused_while_run_live(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
spawn.spawn("proj", "api", task="do it") # fake run stays 'running'
|
|
with get_engine().begin() as conn:
|
|
agent = repo.get_agent_by_name(conn, "proj", "api")
|
|
|
|
ok, detail = spawn.resume(agent, "answer")
|
|
assert ok is False
|
|
assert "live run" in detail
|