[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
@@ -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"})