[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