mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 03:31:36 +00:00
042e27dd15
Worktree spawns now fetch only. The agent's branch is cut from origin/* and the root checkout - which may hold the operator's own work - is never fast-forwarded, merged, or re-parked by a spawn. A failed fetch on a worktree spawn is fatal instead of a silent note: the contract is 'starts at the remote's latest push', and cutting a branch from stale refs would break it quietly. Hardening around the same contract: the branch start falls back origin/HEAD -> origin/main -> origin/master when the head pin is missing, and a stale agent/<name> branch left behind by a deleted agent is reset to the remote tip (-B) instead of silently shadowing it - while -B's refusal to move a branch checked out elsewhere keeps in-flight agents protected. Root/subdir placements (schedule firings, mise-init) keep the fast-forward behavior for their shared tree, as does the explicit sync command. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
392 lines
15 KiB
Python
392 lines
15 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 five 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",
|
|
"SessionStart",
|
|
"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
|
|
|
|
|
|
def test_spawn_trusts_working_dir_in_claude_json(env, fake_launch):
|
|
"""The launch must pre-trust the agent's working dir in ~/.claude.json — an
|
|
untrusted workspace wedges a headless run on the trust dialog with nobody at a
|
|
TTY (regression: the call was lost when phase 4 deleted the tmux launch path)."""
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
|
|
spawn.spawn("proj", "api", task="build the thing")
|
|
|
|
cfg = json.loads((env["tmp"] / ".claude.json").read_text())
|
|
assert cfg["hasCompletedOnboarding"] is True
|
|
entry = cfg["projects"][str(root)]
|
|
assert entry["hasTrustDialogAccepted"] is True
|
|
|
|
|
|
def test_resume_trusts_working_dir_in_claude_json(env, fake_launch):
|
|
"""Cross-worker resume may run in a container that has never seen this working
|
|
dir; resume must re-seed trust exactly as spawn does."""
|
|
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")
|
|
(env["tmp"] / ".claude.json").unlink() # a "fresh container": no config at all
|
|
|
|
ok, _ = spawn.resume(agent, "use Postgres")
|
|
|
|
assert ok is True
|
|
cfg = json.loads((env["tmp"] / ".claude.json").read_text())
|
|
assert cfg["projects"][str(root)]["hasTrustDialogAccepted"] is True
|
|
|
|
|
|
def _git(root, *args):
|
|
import subprocess
|
|
|
|
subprocess.run(
|
|
["git", "-c", "user.email=t@t", "-c", "user.name=t", "-C", str(root), *args],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
|
|
def _init_git_repo(root):
|
|
"""A committed git repo with a test task — the shape of a real synced project."""
|
|
_write_mise(root, with_test=True)
|
|
_git(root, "init", "-q", "-b", "main")
|
|
_git(root, "add", "-A")
|
|
_git(root, "commit", "-q", "-m", "init")
|
|
|
|
|
|
def test_spawn_defaults_to_isolated_worktree(env, fake_launch):
|
|
"""A no-placement spawn on a git root must NOT share the root checkout: the root
|
|
only fast-forwards while parked on the default branch, so shared-root agents saw
|
|
stale trees (missed pushes) as soon as one agent left it on a feature branch."""
|
|
root = env["tmp"] / "proj"
|
|
_init_git_repo(root)
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn("proj", "api", task="build the thing")
|
|
|
|
assert agent["working_dir"] == str(root / "api")
|
|
# A real worktree on the derived branch, not the root itself.
|
|
assert (root / "api" / ".git").exists()
|
|
import subprocess
|
|
|
|
branch = subprocess.run(
|
|
["git", "-C", str(root / "api"), "branch", "--show-current"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
assert branch == "agent/api"
|
|
|
|
|
|
def test_spawn_auto_worktree_off_keeps_root(env, fake_launch):
|
|
root = env["tmp"] / "proj"
|
|
_init_git_repo(root)
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn("proj", "api", task="do it", auto_worktree=False)
|
|
|
|
assert agent["working_dir"] == str(root)
|
|
|
|
|
|
def test_spawn_non_git_root_still_uses_root(env, fake_launch):
|
|
"""Manual (non-git) projects keep the old placement — there is nothing to worktree."""
|
|
root = env["tmp"] / "proj"
|
|
_write_mise(root, with_test=True)
|
|
_register_project(root)
|
|
|
|
agent = spawn.spawn("proj", "api", task="do it")
|
|
|
|
assert agent["working_dir"] == str(root)
|
|
|
|
|
|
def test_worker_spawn_scheduled_keeps_root_placement(env, monkeypatch):
|
|
"""Schedule firings opt out of the worktree default: their continuity convention
|
|
is a state file living in the root tree across runs."""
|
|
from handler.control import spawn as spawn_mod
|
|
from handler.control import worker
|
|
|
|
calls = []
|
|
|
|
def fake_spawn(project_id, name, **kwargs):
|
|
calls.append(kwargs)
|
|
return {"id": 1, "name": name, "working_dir": "/x"}
|
|
|
|
monkeypatch.setattr(spawn_mod, "spawn", fake_spawn)
|
|
|
|
base = {"project_id": "proj", "agent_name": "a", "payload": {"task": "t"}}
|
|
worker._cmd_spawn({**base, "requested_by": "schedule:5"})
|
|
worker._cmd_spawn({**base, "requested_by": "operator:web"})
|
|
|
|
assert calls[0]["auto_worktree"] is False
|
|
assert calls[1]["auto_worktree"] is True
|
|
|
|
|
|
def _git_out(root, *args):
|
|
import subprocess
|
|
|
|
return subprocess.run(
|
|
["git", "-c", "user.email=t@t", "-c", "user.name=t", "-C", str(root), *args],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
|
|
|
|
def test_spawn_worktree_sees_latest_push_and_never_touches_root(env, fake_launch):
|
|
"""The operator's scenario: root clone parked on some agent's old branch, a new
|
|
commit pushed to the remote default branch from elsewhere. The spawned agent's
|
|
worktree must contain that push, and the root checkout must not be moved."""
|
|
bare = env["tmp"] / "origin.git"
|
|
import subprocess
|
|
|
|
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(bare)], check=True)
|
|
|
|
# Seed the remote through a scratch clone: mise config + first commit.
|
|
seed = env["tmp"] / "seed"
|
|
subprocess.run(["git", "clone", "-q", str(bare), str(seed)], check=True)
|
|
_write_mise(seed, with_test=True)
|
|
_git(seed, "add", "-A")
|
|
_git(seed, "commit", "-q", "-m", "init")
|
|
_git(seed, "push", "-q", "origin", "main")
|
|
|
|
# The project root clone, parked on a side branch (what a root-placed agent left).
|
|
root = env["tmp"] / "proj"
|
|
subprocess.run(["git", "clone", "-q", str(bare), str(root)], check=True)
|
|
_git(root, "checkout", "-q", "-b", "old-agent-branch")
|
|
|
|
# The operator's hand-made commit, pushed to main from a different checkout.
|
|
(seed / "HANDOFF.md").write_text("the context the agent needs\n")
|
|
_git(seed, "add", "-A")
|
|
_git(seed, "commit", "-q", "-m", "operator context")
|
|
_git(seed, "push", "-q", "origin", "main")
|
|
|
|
with get_engine().begin() as conn:
|
|
repo.create_project(conn, "proj", str(root), git_remote=str(bare))
|
|
|
|
agent = spawn.spawn("proj", "api", task="use the handoff")
|
|
|
|
# The worktree starts at the remote tip: the operator's push is present.
|
|
wt = env["tmp"] / "proj" / "api"
|
|
assert agent["working_dir"] == str(wt)
|
|
assert (wt / "HANDOFF.md").read_text() == "the context the agent needs\n"
|
|
assert _git_out(wt, "branch", "--show-current") == "agent/api"
|
|
# The root checkout was not moved, merged, or re-parked.
|
|
assert _git_out(root, "branch", "--show-current") == "old-agent-branch"
|
|
|
|
|
|
def test_spawn_derived_branch_resets_stale_leftover(env, fake_launch):
|
|
"""A stale agent/<name> branch from a deleted agent must not shadow the remote
|
|
tip — derived branches are reset to the remote default when recreated."""
|
|
bare = env["tmp"] / "origin.git"
|
|
import subprocess
|
|
|
|
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(bare)], check=True)
|
|
seed = env["tmp"] / "seed"
|
|
subprocess.run(["git", "clone", "-q", str(bare), str(seed)], check=True)
|
|
_write_mise(seed, with_test=True)
|
|
_git(seed, "add", "-A")
|
|
_git(seed, "commit", "-q", "-m", "init")
|
|
_git(seed, "push", "-q", "origin", "main")
|
|
|
|
root = env["tmp"] / "proj"
|
|
subprocess.run(["git", "clone", "-q", str(bare), str(root)], check=True)
|
|
# A stale derived branch parked at the old tip (its worktree long gone).
|
|
_git(root, "branch", "agent/api")
|
|
|
|
# The remote advances past it.
|
|
(seed / "NEW.md").write_text("newer\n")
|
|
_git(seed, "add", "-A")
|
|
_git(seed, "commit", "-q", "-m", "newer")
|
|
_git(seed, "push", "-q", "origin", "main")
|
|
|
|
with get_engine().begin() as conn:
|
|
repo.create_project(conn, "proj", str(root), git_remote=str(bare))
|
|
|
|
agent = spawn.spawn("proj", "api", task="do it")
|
|
|
|
wt = env["tmp"] / "proj" / "api"
|
|
assert agent["working_dir"] == str(wt)
|
|
assert (wt / "NEW.md").exists() # reset to the remote tip, not the stale branch
|