diff --git a/CHANGELOG.md b/CHANGELOG.md index 137b77f9..d97fba73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/roboco/services/git.py b/roboco/services/git.py index fb75ba3a..6a4901d2 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -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() diff --git a/tests/unit/services/test_git.py b/tests/unit/services/test_git.py index ce838eb7..999f0690 100644 --- a/tests/unit/services/test_git.py +++ b/tests/unit/services/test_git.py @@ -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" + )