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:
Claude
2026-08-13 19:30:10 +00:00
parent e7d08ef3de
commit b4ad63c687
4 changed files with 128 additions and 0 deletions
+14
View File
@@ -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
+23
View File
@@ -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
+6
View File
@@ -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"],
+85
View File
@@ -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