Files
handler/tests/test_control_spawn.py
T
Claude 042e27dd15 Root checkout is a ref store: worktree spawns never touch it
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
2026-08-14 18:54:53 +00:00

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