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