mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user