mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-02 01:16:25 +00:00
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:
+75
-27
@@ -1,4 +1,7 @@
|
||||
"""Control-layer spawn: the hard test-task gate, settings generation, identity env."""
|
||||
"""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
|
||||
|
||||
@@ -24,36 +27,48 @@ def _write_mise(root, with_test=True):
|
||||
(root / ".mise.toml").write_text(body)
|
||||
|
||||
|
||||
def test_spawn_refuses_without_test_task(env, fake_tmux):
|
||||
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")
|
||||
assert fake_tmux["calls"]["new_session"] == []
|
||||
spawn.spawn("proj", "api", task="do it")
|
||||
assert fake_launch == []
|
||||
|
||||
|
||||
def test_spawn_refuses_without_mise_file(env, fake_tmux):
|
||||
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_tmux):
|
||||
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")
|
||||
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_session(env, fake_tmux):
|
||||
def test_spawn_creates_agent_settings_and_run(env, fake_launch):
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root, with_test=True)
|
||||
_register_project(root)
|
||||
@@ -64,66 +79,99 @@ def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
|
||||
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.
|
||||
# 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"]
|
||||
|
||||
# tmux session named project__agent, with identity + DATABASE_URL in env.
|
||||
call = fake_tmux["calls"]["new_session"][0]
|
||||
assert call["name"] == "proj__api"
|
||||
# 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_tmux):
|
||||
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", require_tests=False, mise_init=True)
|
||||
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 session carries HANDLER_MISE_INIT so its hooks enforce commit + push.
|
||||
call = fake_tmux["calls"]["new_session"][0]
|
||||
assert call["env"]["HANDLER_MISE_INIT"] == "1"
|
||||
# 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_tmux):
|
||||
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")
|
||||
assert fake_tmux["calls"]["new_session"] == []
|
||||
spawn.spawn("proj", "api", task="do it")
|
||||
assert fake_launch == []
|
||||
|
||||
|
||||
def test_kill_sets_done_and_kills_session(env, fake_tmux):
|
||||
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")
|
||||
spawn.spawn("proj", "api", task="do it")
|
||||
|
||||
spawn.kill("proj", "api")
|
||||
assert "proj__api" in fake_tmux["calls"]["kill_session"]
|
||||
with get_engine().begin() as conn:
|
||||
assert repo.get_agent_by_name(conn, "proj", "api")["status"] == "done"
|
||||
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_sends_answer_to_live_session(env, fake_tmux):
|
||||
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)
|
||||
agent = spawn.spawn("proj", "api")
|
||||
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 fake_tmux["calls"]["send_keys"][0] == {"name": "proj__api", "keys": "use Postgres"}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user