From b4ad63c68714bccd5c728615694de93b0ce494ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:30:10 +0000 Subject: [PATCH] Default operator spawns to a fresh worktree cut from origin/HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, 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 Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48 --- CHANGELOG.md | 14 ++++++ src/handler/control/spawn.py | 23 ++++++++++ src/handler/control/worker.py | 6 +++ tests/test_control_spawn.py | 85 +++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index abeff95..6a393d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` 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 diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index a2e158d..b2bff50 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -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/`` 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/ 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 diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py index a86b32a..210eb69 100644 --- a/src/handler/control/worker.py +++ b/src/handler/control/worker.py @@ -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"], diff --git a/tests/test_control_spawn.py b/tests/test_control_spawn.py index 629274a..9d69583 100644 --- a/tests/test_control_spawn.py +++ b/tests/test_control_spawn.py @@ -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