fix(git): fetch and track missing parent branch during leaf PR merge

_sync_target_branch previously did a bare 'git checkout <target>' with no
fallback. In the shared-agent-clone model the leaf developer's workspace
often has only the task branch locally; the parent/cell branch exists only
on origin. That produced a SERVICE_ERROR which cycled the task back to
blocked every time the PM retried complete().

Now, when checkout fails, we fetch the target branch from origin and create a
tracking branch before the pull. Includes regression tests for the local,
fallback, and origin-missing paths.
This commit is contained in:
Renn F
2026-06-24 18:42:36 +02:00
parent dcb80dfc18
commit 4a18dbe4c8
3 changed files with 126 additions and 3 deletions
+3
View File
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added
- **MegaTask — describe several tasks in one intake chat and ship them as one sequenced batch.** When the CEO wants several pieces of work at once — even across projects that don't share a codebase (e.g. a SaaS app, its open-source core engine, and a framework adapter) — the intake modal now offers a third scope, **MegaTask**, beside Single cell and Board-led. You pick the repos it spans; the intake agent reads them all and proposes the whole batch in one hand-off (the new `propose_batch` tool), one draft per task, each carrying its own project plus a collision surface (which files it touches, whether it adds a migration, whether it edits a widely-shared component). A deterministic analyzer (`SequencingService`) turns those surfaces into conflict-free **waves** — file-overlap and migration-adding tasks are serialized, a shared-surface edit runs after what it overlaps, independent tasks run in parallel — and the Board reviews the batch once. On confirm RoboCo creates a branchless **umbrella** task (the Main PM's coordination + board-review + CEO-approve unit) over N **root-subtasks**, each a real coordination root with its own project, branch, and PR, wired with the analyzer's dependencies so the existing dependency-gate dispatches the waves in order. The umbrella assembles no PR of its own, is exempt from the branch gate, and completes only when every root-subtask is terminal (then it escalates to the CEO). On the Board route the root-subtasks are held until the umbrella is approved, then released. Surfaced as a core capability — no feature flag — branded "MegaTask" across the panel, prompts, and docs; internal names stay technical (`batch_id`, `SequencingService`). Adds `tasks.batch_id` + the three collision-surface columns (migration 046), `confirm_live_batch` + `POST /prompter/live/{session}/confirm-batch`, multi-project intake spawn (`project_ids`), the `propose_batch` tool on both intake runtimes (Claude SDK driver + grok CLI server), and the panel's MegaTask scope + Review-MegaTask card.
- **New Ollama Cloud models in the LLM catalog.** Added `kimi-k2.7-code:cloud` and `nemotron-3-ultra:cloud` to the Settings model picker. `north-mini-code-1.0` is available only as a self-hosted Ollama tag, so it is left out of the cloud catalog and will appear automatically in the self-hosted picker when pulled locally.
### Fixed
@@ -41,6 +42,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **A feature flag stopped showing its raw internal key.** In Settings → Feature Flags, the "Gateway-health recovery" toggle displayed its raw key `gateway_health_enabled` as its description (the only flag missing a human blurb). Added the description, and changed the fallback so a future flag without one renders nothing rather than leaking a snake_case key.
- **A missing local parent branch no longer blocks every leaf PR merge.** When a cell PM completes a leaf task, `_sync_target_branch` checked out the parent/cell branch with a bare `git checkout <branch>` and no fallback — but the agent's shared clone often only has the leaf's own task branch locally, while the parent branch exists only on `origin`. That produced a "git workspace state inconsistent" SERVICE_ERROR that cycled the task back to `blocked` each time the PM retried. The merge path now fetches the target branch from origin and creates a tracking branch when the local ref is missing, then pulls and returns the merge commit the same as before.
## [0.10.0] - 2026-06-23
### Added
+34 -2
View File
@@ -2492,8 +2492,40 @@ class GitService(BaseService):
async def _sync_target_branch(
self, workspace: Path, target_branch: str, git_token: str
) -> str:
"""Checkout + pull the target branch, return the tip commit hash."""
await self._run_git(workspace, ["checkout", target_branch])
"""Checkout + pull the target branch, return the tip commit hash.
If the target branch has no local ref (common in agent workspaces that
only ever checked out their own task branch), fetch it from origin and
create a tracking branch before pulling. This prevents the "parent
branch doesn't exist locally" SERVICE_ERROR that blocks every leaf→cell
merge in a shared workspace.
"""
checkout = await self._run_git(
workspace, ["checkout", target_branch], check=False
)
if checkout.returncode != 0:
# Local ref missing — fetch the single branch from origin and create
# a tracking branch. A full `git fetch` is avoided because the
# workspace may hold many refs; narrowing to the target keeps it
# cheap and avoids token-burn on a large repo.
await self._run_git(
workspace, ["fetch", "origin", target_branch], token=git_token
)
tracking = await self._run_git(
workspace,
["checkout", "-b", target_branch, f"origin/{target_branch}"],
check=False,
)
if tracking.returncode != 0:
raise GitError(
f"Could not check out target branch '{target_branch}' "
"locally or from origin.",
{
"target_branch": target_branch,
"checkout_stderr": checkout.stderr.strip(),
"tracking_stderr": tracking.stderr.strip(),
},
)
await self._run_git(workspace, ["pull"], token=git_token)
log_result = await self._run_git(workspace, ["log", "-1", "--format=%H"])
return log_result.stdout.strip()
+89 -1
View File
@@ -15,7 +15,7 @@ from uuid import uuid4
import pytest
from roboco.api.schemas.git import GitCreateBranchRequest
from roboco.config import settings
from roboco.exceptions import GitCommandError
from roboco.exceptions import GitCommandError, GitError
from roboco.services.base import NotFoundError, UnauthorizedError
from roboco.services.git import GitService
@@ -797,3 +797,91 @@ async def test_push_propagates_non_gh001_error_unchanged() -> None:
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
with pytest.raises(GitCommandError, match="Authentication failed"):
await svc.push(Path("/tmp/ws"))
# ---------------------------------------------------------------------------
# _sync_target_branch fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sync_target_branch_uses_local_ref_when_present() -> None:
"""If the target branch already exists locally, just checkout + pull."""
svc = _service()
calls: list[list[str]] = []
async def _run_git(_workspace: object, args: list[str], **_kw: object) -> object:
calls.append(args)
if args[:2] == ["checkout", "feature/backend/parent"]:
return MagicMock(returncode=0, stdout="", stderr="")
if args[:1] == ["pull"]:
return MagicMock(returncode=0, stdout="", stderr="")
if args[:2] == ["log", "-1"]:
return MagicMock(returncode=0, stdout="local-tip-sha", stderr="")
return MagicMock(returncode=0, stdout="", stderr="")
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
sha = await svc._sync_target_branch(
Path("/tmp/ws"), "feature/backend/parent", "token"
)
assert sha == "local-tip-sha"
assert ["checkout", "feature/backend/parent"] in calls
assert ["pull"] in calls
assert ["fetch", "origin", "feature/backend/parent"] not in calls
@pytest.mark.asyncio
async def test_sync_target_branch_fetches_and_tracks_when_local_ref_missing() -> None:
"""A parent branch that only exists on origin is fetched and tracked."""
svc = _service()
calls: list[list[str]] = []
async def _run_git(_workspace: object, args: list[str], **_kw: object) -> object:
calls.append(args)
if args[:2] == ["checkout", "feature/backend/parent"]:
return MagicMock(returncode=1, stdout="", stderr="pathspec did not match")
if args[:2] == ["fetch", "origin"]:
return MagicMock(returncode=0, stdout="", stderr="")
if args[:3] == ["checkout", "-b", "feature/backend/parent"]:
return MagicMock(returncode=0, stdout="", stderr="")
if args[:1] == ["pull"]:
return MagicMock(returncode=0, stdout="", stderr="")
if args[:2] == ["log", "-1"]:
return MagicMock(returncode=0, stdout="origin-tip-sha", stderr="")
return MagicMock(returncode=0, stdout="", stderr="")
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
sha = await svc._sync_target_branch(
Path("/tmp/ws"), "feature/backend/parent", "token"
)
assert sha == "origin-tip-sha"
assert ["checkout", "feature/backend/parent"] in calls
assert ["fetch", "origin", "feature/backend/parent"] in calls
assert [
"checkout",
"-b",
"feature/backend/parent",
"origin/feature/backend/parent",
] in calls
assert ["pull"] in calls
@pytest.mark.asyncio
async def test_sync_target_branch_raises_when_origin_also_missing() -> None:
"""If the branch is missing locally AND on origin, raise a clear GitError."""
svc = _service()
async def _run_git(_workspace: object, args: list[str], **_kw: object) -> object:
if args[:2] == ["checkout", "feature/backend/parent"]:
return MagicMock(returncode=1, stdout="", stderr="local missing")
if args[:2] == ["fetch", "origin"]:
return MagicMock(returncode=0, stdout="", stderr="")
if args[:3] == ["checkout", "-b", "feature/backend/parent"]:
return MagicMock(returncode=1, stdout="", stderr="origin missing")
return MagicMock(returncode=0, stdout="", stderr="")
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
with pytest.raises(GitError, match="Could not check out target branch"):
await svc._sync_target_branch(
Path("/tmp/ws"), "feature/backend/parent", "token"
)