[feature] sync_branch dev verb — gate-level branch rebase (Phase B1)

Raw shell git is denied to agents (Bash(git:*) base deny), so a developer
whose branch fell behind its base had no gate-level rebase — only the
CEO/PM-only /rebase HTTP route. sync_branch is the dev verb that wraps the
rebase through the gate (traced + evidenced), so the 'everything goes through
the gates' invariant holds.

- lifecycle: IntentSpec sync_branch (dev-only, ownership-gated, composes=(),
  git-only — no DB transition); _next_hint_synced helper.
- GitService.sync_task_branch: rebase task.branch_name onto its resolved base
  via rebase_onto_base (fetch + rebase + force-with-lease push).
- Choreographer.sync_branch + _sync_branch_preflight_rejection: not_found /
  unknown-role / spec-gate / no-branch / protected-base guards, then the git
  op; conflicts abort (no force-push) and steer to resolve-by-hand; git failure
  steers to i_am_blocked.
- HTTP route /api/v1/flow/developer/sync_branch + SyncBranchRequest schema.
- MCP tool sync_branch(task_id) + _TOOLS registration (manifest auto-propagates
  via intents_for_role(Role.DEVELOPER)).

Tests: intent spec (5), choreographer handler (8: happy/conflicts/not_found/
not_authorized/no-branch/protected-base/git-failure/audit), route (1), MCP (1).
ruff + mypy roboco/ tests/ clean; unit suite green (DB-fixture errors env-only).
This commit is contained in:
Renn F
2026-06-28 04:40:06 +02:00
parent 9927d248ea
commit 250be5c246
10 changed files with 607 additions and 0 deletions
+19
View File
@@ -205,3 +205,22 @@ async def test_resume_dispatches_task_id() -> None:
)
assert resp.status_code == _HTTP_200
mock_chore.resume.assert_awaited_once()
@pytest.mark.asyncio
async def test_sync_branch_dispatches_task_id() -> None:
"""POST sync_branch forwards task_id to Choreographer.sync_branch (git-only)."""
mock_chore = MagicMock()
mock_chore.sync_branch = AsyncMock(
return_value=_make_envelope(status="ok", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v1/flow/developer/sync_branch",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_chore.sync_branch.assert_awaited_once()
# the only positional arg beyond x_agent_id is task_id
assert str(mock_chore.sync_branch.call_args.args[1]) == _TASK_ID
@@ -0,0 +1,77 @@
"""IntentSpec for the dev `sync_branch` verb (multi-level sequencing Phase B1).
Raw shell git is denied to agents by design (the `Bash(git:*)` base deny), so a
developer whose branch has fallen behind its base had no gate-level way to
rebase only the CEO/PM-only `/rebase` HTTP route. `sync_branch` is the dev
verb that wraps the git rebase through the gate (traced + evidenced), so the
"everything goes through the gates" invariant holds. These tests lock the spec
declaration: dev-only, ownership-gated, composes nothing (git-only, no DB
transition), and present in the dev flow.
"""
from __future__ import annotations
from dataclasses import dataclass
from uuid import uuid4
from roboco.foundation.identity import Role
from roboco.foundation.policy.lifecycle import (
Context,
Decision,
can_invoke_intent,
intents_for_role,
)
from roboco.services.gateway.role_config import _DEV_FLOW
def test_sync_branch_is_a_dev_flow_verb() -> None:
# Declared with _DEV_ROLES, so intents_for_role propagates it into the dev
# flow automatically — no role_config edit needed (the spec is canon).
assert "sync_branch" in intents_for_role(Role.DEVELOPER)
assert "sync_branch" in _DEV_FLOW
def test_sync_branch_is_dev_only() -> None:
# QA / documenter / PM cannot call it — it's a developer's branch-sync verb.
for role in (Role.QA, Role.DOCUMENTER, Role.CELL_PM, Role.MAIN_PM):
assert "sync_branch" not in intents_for_role(role), (
f"{role} must not get sync_branch"
)
@dataclass
class _Task:
assigned_to: object = None
def test_sync_branch_requires_ownership() -> None:
# PRECONDITION_OWNERSHIP (rejection_kind='not_authorized') gates it: a task
# assigned to another agent rejects as not_authorized, not a tracing gap.
owner = uuid4()
other = uuid4()
task = _Task(assigned_to=other)
decision = can_invoke_intent(
Role.DEVELOPER, "sync_branch", task, Context(actor_id=owner)
)
assert not decision.allowed
assert decision.rejection_kind == "not_authorized"
def test_sync_branch_allowed_when_owner() -> None:
# composes=() and the only precondition is ownership, so the owner passes the
# spec gate. (The choreographer handler does the git work + branch/base
# guards separately.)
owner = uuid4()
task = _Task(assigned_to=owner)
decision = can_invoke_intent(
Role.DEVELOPER, "sync_branch", task, Context(actor_id=owner)
)
assert isinstance(decision, Decision)
assert decision.allowed
def test_sync_branch_unknown_to_other_role_rejects() -> None:
# A role not in allowed_roles is rejected as not_authorized (role gating).
decision = can_invoke_intent(Role.QA, "sync_branch", _Task(), Context())
assert not decision.allowed
assert decision.rejection_kind == "not_authorized"
+247
View File
@@ -0,0 +1,247 @@
"""sync_branch rebases the caller's task branch onto its base THROUGH the gate.
Multi-level sequencing Phase B1. Raw shell git is denied to agents
(``Bash(git:*)`` base deny), so a developer whose branch fell behind its base
had no gate-level rebase only the CEO/PM-only ``/rebase`` HTTP route.
``sync_branch`` is the dev verb that wraps the rebase through the gate
(traced + evidenced), so the "everything goes through the gates" invariant
holds. These tests pin the handler:
- happy path: git.sync_task_branch runs, evidence carries the rebase result,
heartbeat fires
- conflicts: rebase aborted, next_hint points the dev at resolve-by-hand
- not_found: unknown task id
- not_authorized: only the current claimant can sync (ownership gate)
- no branch: branchless / not-yet-claimed task invalid_state, steer to
i_will_work_on
- protected base: resolved base == master/main invalid_state (defense-in-depth)
- git failure: sync_task_branch raises invalid_state, steer to i_am_blocked
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_deps(**overrides: object) -> ChoreographerDeps:
base: dict[str, object] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
assert isinstance(repo, AsyncMock)
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
):
getattr(repo, method).return_value = []
return ChoreographerDeps(**base)
_BRANCH = "feature/backend/abc12345"
_BASE = "feature/backend/parent12345"
def _task(*, tid: object, aid: object, branch: str | None = _BRANCH) -> MagicMock:
return MagicMock(
id=tid,
status="in_progress",
assigned_to=aid,
branch_name=branch,
)
@pytest.mark.asyncio
async def test_sync_branch_rebases_and_returns_evidence() -> None:
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.sync_task_branch.return_value = {
"status": "rebased",
"commits_rebased": 3,
}
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value=_BASE),
):
env = await c.sync_branch(aid, tid)
git_svc.sync_task_branch.assert_awaited_once_with(
t, base_branch=_BASE, actor_agent_id=aid
)
assert env.error is None
assert env.evidence is not None
assert env.evidence["base_branch"] == _BASE
assert env.evidence["head_branch"] == _BRANCH
assert env.evidence["rebase"]["status"] == "rebased"
task_svc.heartbeat.assert_awaited_once_with(tid)
@pytest.mark.asyncio
async def test_sync_branch_conflicts_aborts_and_steers_to_resolve() -> None:
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.sync_task_branch.return_value = {
"status": "conflicts",
"files": ["src/a.py", "src/b.py"],
}
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value=_BASE),
):
env = await c.sync_branch(aid, tid)
assert env.error is None
assert env.next is not None
assert "resolve by hand" in env.next
assert "sync_branch again" in env.next
@pytest.mark.asyncio
async def test_sync_branch_not_found_for_unknown_task() -> None:
aid = uuid4()
tid = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.sync_branch(aid, tid)
assert env.error == "not_found"
@pytest.mark.asyncio
async def test_sync_branch_rejects_when_not_claimant() -> None:
aid = uuid4()
other = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=other)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
env = await c.sync_branch(aid, tid)
# PRECONDITION_OWNERSHIP rejects a non-owner as not_authorized.
assert env.error == "not_authorized"
git_svc.sync_task_branch.assert_not_awaited()
@pytest.mark.asyncio
async def test_sync_branch_no_branch_steers_to_i_will_work_on() -> None:
aid = uuid4()
tid = uuid4()
# branch_name=None — task not yet claimed / branchless coordination root.
t = _task(tid=tid, aid=aid, branch=None)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
env = await c.sync_branch(aid, tid)
assert env.error == "invalid_state"
assert "i_will_work_on" in (env.remediate or "")
git_svc.sync_task_branch.assert_not_awaited()
@pytest.mark.asyncio
async def test_sync_branch_refuses_protected_base() -> None:
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
# Defense-in-depth: a base that resolved to master must never be rebased into.
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value="master"),
):
env = await c.sync_branch(aid, tid)
assert env.error == "invalid_state"
assert "protected" in (env.message or "")
git_svc.sync_task_branch.assert_not_awaited()
@pytest.mark.asyncio
async def test_sync_branch_git_failure_steers_to_i_am_blocked() -> None:
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.sync_task_branch.side_effect = RuntimeError("network down")
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value=_BASE),
):
env = await c.sync_branch(aid, tid)
assert env.error == "invalid_state"
assert "i_am_blocked" in (env.remediate or "")
@pytest.mark.asyncio
async def test_sync_branch_rejection_writes_audit_row() -> None:
"""Every rejection envelope must call audit.log_event (Task 6 contract)."""
aid = uuid4()
tid = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.sync_branch(aid, tid)
assert env.error == "not_found"
audit_svc.log_event.assert_awaited_once()
kwargs = audit_svc.log_event.await_args.kwargs
assert kwargs["event_type"] == "gateway.rejected"
assert kwargs["details"]["verb"] == "sync_branch"
@@ -29,6 +29,7 @@ _FULL_MANIFEST = {
"i_am_blocked",
"unclaim",
"resume",
"sync_branch",
"i_am_idle",
"claim_review",
"pass",
@@ -214,6 +215,19 @@ def test_i_am_done_notes_defaults_to_empty(flow_module: types.ModuleType) -> Non
assert kwargs["json"]["notes"] == ""
def test_sync_branch_posts_to_dev_path(flow_module: types.ModuleType) -> None:
"""sync_branch forwards task_id to /api/v1/flow/developer/sync_branch."""
fake_client = _make_fake_client({"status": "ok"})
with patch("httpx.Client", return_value=fake_client):
result = flow_module.sync_branch("task-abc")
assert result == {"status": "ok"}
args, kwargs = fake_client.post.call_args
assert "/api/v1/flow/developer/sync_branch" in args[0]
assert kwargs["json"] == {"task_id": "task-abc"}
def test_i_am_blocked_sends_reason(flow_module: types.ModuleType) -> None:
fake_client = _make_fake_client({"status": "blocked"})