mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 04:46:25 +00:00
Default operator spawns to a fresh worktree cut from origin/HEAD
A spawn with no explicit placement ran the agent in the shared project-root checkout. The root only fast-forwards while it is parked on the default branch, so the moment one agent left it on a feature branch, every later no-placement spawn — the mobile app always, the web form whenever the branch field was blank — started from a stale tree: the operator's freshly pushed commit was fetched into origin/* but invisible to the agent. Operator spawns on a git root now default to a worktree on agent/<name>, cut from origin/HEAD like any explicit worktree spawn, so a new agent always starts at the remote's latest push and gets the per-agent isolation the README promises. Schedule firings opt out (auto_worktree=False): their continuity convention is a state file living in the root tree across runs. The mise-init bootstrap and non-git roots keep root placement unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
@@ -6,6 +6,20 @@ the image workflows publish (plus `latest` from every push to `main`).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Agents missing freshly pushed commits.** A spawn with no explicit placement ran
|
||||
the agent in the shared project-root checkout, and the root only fast-forwards
|
||||
while parked on the default branch — so as soon as one agent left it on a feature
|
||||
branch, every later no-placement spawn (the mobile app always, the web form with an
|
||||
empty branch field) started from a stale tree even though the push had been
|
||||
fetched. Operator spawns on a git root now default to a fresh worktree on
|
||||
`agent/<name>` cut from `origin/HEAD` — always the remote's latest push, and real
|
||||
per-agent isolation (README's "one working directory or git worktree per agent").
|
||||
Explicit worktree/subdir placements are honored unchanged; schedule firings and the
|
||||
mise-init bootstrap keep root placement (their conventions depend on the root
|
||||
tree); non-git roots are untouched. 4 regression tests.
|
||||
|
||||
### Added
|
||||
|
||||
- **Tappable fleet stat cards** in the mobile app: Running / Waiting / Done now open a
|
||||
|
||||
@@ -77,6 +77,7 @@ def spawn(
|
||||
require_tests: bool = True,
|
||||
mise_init: bool = False,
|
||||
worker_id: str | None = None,
|
||||
auto_worktree: bool = True,
|
||||
) -> dict:
|
||||
"""Create and launch an agent. Returns the agent row.
|
||||
|
||||
@@ -88,6 +89,14 @@ def spawn(
|
||||
instead of the worker's Claude subscription — same binary, same hooks/skills/gates,
|
||||
different ``ANTHROPIC_*`` env. ``worker_id`` identifies the calling worker container
|
||||
(headless runs record it on the run row; the CLI defaults to a pid-scoped id).
|
||||
|
||||
``auto_worktree``: when no placement is given and the root is a git repo, default to
|
||||
a fresh worktree on ``agent/<name>`` instead of the shared root checkout. The root
|
||||
only fast-forwards while parked on the default branch, so agents sharing it saw
|
||||
stale trees the moment one of them left it on a feature branch — a worktree cut
|
||||
from ``origin/HEAD`` always starts at the remote's latest push (README's "one
|
||||
working directory or git worktree per agent"). Schedule firings pass False: their
|
||||
continuity convention is a state file living in the root tree across runs.
|
||||
"""
|
||||
if not task:
|
||||
# ``claude -p`` has no idle-REPL mode — an empty prompt would exit immediately
|
||||
@@ -121,6 +130,20 @@ def spawn(
|
||||
except reposync.SyncError as exc:
|
||||
raise SpawnError(str(exc)) from exc
|
||||
|
||||
if (
|
||||
auto_worktree
|
||||
and worktree_branch is None
|
||||
and subdir is None
|
||||
and not mise_init # the bootstrap commits .mise.toml to the default branch
|
||||
and gitops.is_repo(root)
|
||||
):
|
||||
# Isolation by default: without this, every no-placement spawn shares the
|
||||
# root checkout, whose tree goes stale as soon as an agent parks it off the
|
||||
# default branch. (Name reuse after a deleted agent can leave a stale
|
||||
# agent/<name> branch behind; it is then checked out as-is, same as any
|
||||
# explicitly named existing branch.)
|
||||
worktree_branch = f"agent/{name}"
|
||||
|
||||
try:
|
||||
working_dir = worktree.resolve_working_dir(
|
||||
project["root_dir"], name, subdir=subdir, worktree_branch=worktree_branch
|
||||
|
||||
@@ -50,6 +50,11 @@ def _cmd_spawn(command: dict) -> dict:
|
||||
name = command.get("agent_name") or p.get("name")
|
||||
if not command.get("project_id") or not name:
|
||||
raise CommandError("spawn requires project_id and an agent name")
|
||||
# Schedule firings keep the legacy root placement when their schedule sets no
|
||||
# worktree/subdir: the scheduled-run convention parks continuity in a state file
|
||||
# in the root tree, which a fresh per-run worktree would not see. Operator spawns
|
||||
# default to an isolated worktree cut from origin/HEAD (spawn.auto_worktree).
|
||||
scheduled = (command.get("requested_by") or "").startswith("schedule:")
|
||||
agent = spawn.spawn(
|
||||
command["project_id"],
|
||||
name,
|
||||
@@ -59,6 +64,7 @@ def _cmd_spawn(command: dict) -> dict:
|
||||
role=p.get("role"),
|
||||
model_id=p.get("model_id"),
|
||||
worker_id=command.get("claimed_by"),
|
||||
auto_worktree=not scheduled,
|
||||
)
|
||||
result = {
|
||||
"agent_id": agent["id"],
|
||||
|
||||
@@ -216,3 +216,88 @@ def test_resume_trusts_working_dir_in_claude_json(env, fake_launch):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user