[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
+12
View File
@@ -15,6 +15,7 @@ from roboco.api.schemas.v1.flow import (
IWillWorkOnRequest,
OpenPrRequest,
ResumeRequest,
SyncBranchRequest,
UnclaimRequest,
)
from roboco.services.gateway.choreographer import Choreographer
@@ -121,6 +122,17 @@ async def resume(
return envelope_to_response(env, request)
@router.post("/sync_branch")
async def sync_branch(
request: Request,
body: SyncBranchRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.sync_branch(x_agent_id, body.task_id)
return envelope_to_response(env, request)
@router.post("/i_am_idle")
async def i_am_idle(
request: Request,
+11
View File
@@ -114,6 +114,17 @@ class ResumeRequest(BaseModel):
task_id: UUID
class SyncBranchRequest(BaseModel):
"""HTTP body for the dev `sync_branch` verb.
Rebases the task's branch onto its resolved base (parent branch) through the
gate, so a developer whose branch has fallen behind can re-sync without raw
git (which is denied to agents). Git-only — no DB state transition.
"""
task_id: UUID
class IAmIdleRequest(BaseModel):
"""Empty request body."""
+24
View File
@@ -740,6 +740,13 @@ def _next_hint_open_pr(_t: Any) -> str:
return "PR opened; call i_am_done(task_id, notes='...') when self-verified"
def _next_hint_synced(_t: Any) -> str:
return (
"branch synced onto its base; continue editing + commit(message),"
" then open_pr(task_id) / i_am_done(task_id)"
)
def _next_hint_after_claim(_t: Any) -> str:
return (
"edit + commit(message) for each meaningful change,"
@@ -1008,6 +1015,23 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
side_effects=(),
next_hint=_next_hint_idle,
),
"sync_branch": IntentSpec(
name="sync_branch",
allowed_roles=_DEV_ROLES,
description=(
"Rebase your task's branch onto its current base THROUGH the gate"
" (raw git is denied). Use when your branch has fallen behind its"
" base — e.g. a sibling task's PR merged into the parent branch"
" while you worked. Fetches origin, rebases head onto base, and"
" force-pushes (with-lease). No DB state change. On conflicts the"
" rebase is aborted and the conflicted files are returned — resolve"
" by hand, commit, then sync_branch again."
),
composes=(), # git-only verb — no DB transition; the handler runs the git op
extra_preconditions=(PRECONDITION_OWNERSHIP,),
side_effects=(),
next_hint=_next_hint_synced,
),
"i_am_blocked": IntentSpec(
name="i_am_blocked",
allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES),
+16
View File
@@ -333,6 +333,21 @@ def resume(task_id: str) -> dict[str, Any]:
return _post(_role_path("resume"), {"task_id": task_id})
def sync_branch(task_id: str) -> dict[str, Any]:
"""Re-sync your branch onto its base through the gate.
Rebases the task's branch onto its resolved parent/base branch (fetch +
rebase + force-with-lease push). Use this when your branch has fallen
behind its base and you need to pick up merged work before continuing
raw git is denied, so this is the gate-level way to rebase. No lifecycle
transition: after it returns, keep editing + commit, then open_pr /
i_am_done as normal. On ``conflicts`` status the envelope's ``next`` tells
you the rebase aborted and your branch is unchanged resolve the conflict
in your working tree first (the gate does not force a conflicted rebase).
"""
return _post(_role_path("sync_branch"), {"task_id": task_id})
def i_am_idle() -> dict[str, Any]:
"""Report no more work. Soft-blocks if you have unread A2A/mentions."""
return _post(_role_path("i_am_idle"), {})
@@ -602,6 +617,7 @@ _TOOLS: dict[str, Any] = {
"unclaim": unclaim,
"reassign": reassign,
"resume": resume,
"sync_branch": sync_branch,
"i_am_idle": i_am_idle,
# qa — keys are the public MCP tool names (what agents see and prompts
# advertise). `pass`/`fail` are Python keywords so the IntentSpec uses
@@ -3310,6 +3310,156 @@ class Choreographer:
context_briefing=briefing,
).with_introspection(task=after, role=role_str)
async def sync_branch(self, agent_id: UUID, task_id: UUID) -> Envelope:
"""Rebase the caller's task branch onto its current base THROUGH the gate.
Raw shell git is denied to agents (the ``Bash(git:*)`` base deny), so a
developer whose branch fell behind its base a sibling's PR merged
into the parent branch while they worked had no gate-level rebase,
only the CEO/PM-only ``/rebase`` HTTP route. ``sync_branch`` is that
gate verb: it resolves the task's base via
``merge_chain.resolve_parent_branch``, guards against rebasing into a
protected branch, then rebases + force-pushes (with-lease) via
``GitService.sync_task_branch``. Git-only (no DB state change), like
``open_pr``; the spec gate checks role + ownership, then the handler
guards branch + base, then runs the git op. Conflicts abort the rebase
(no force-push) and return the conflicted files resolve by hand,
commit, then sync_branch again.
"""
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id, task=t)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "developer"
# Preflight: not_found / unknown-role / spec-gate / no-branch /
# protected-base. Returns the rejection (None when all clear) AND the
# resolved base_branch the git op needs (empty when rejected).
rejection, base_branch = await self._sync_branch_preflight_rejection(
agent_id, task_id, t, agent, role_str, briefing
)
if rejection is not None:
return await self._emit_rejection(
rejection, agent_id=agent_id, task_id=task_id, verb="sync_branch"
)
try:
result = await self.git.sync_task_branch(
t, base_branch=base_branch, actor_agent_id=agent_id
)
except Exception as exc:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"sync_branch failed: {exc}",
remediate=(
"the git rebase could not complete; escalate via"
" i_am_blocked(reason='...') with the error"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="sync_branch",
)
# Heartbeat — the agent is actively working the task.
await self._touch(task_id)
status = str(result.get("status", "unknown"))
evidence = {
"rebase": result,
"base_branch": base_branch,
"head_branch": str(t.branch_name),
}
if status == "conflicts":
# The rebase was aborted (no force-push); tell the dev to resolve.
next_hint = (
f"sync_branch hit conflicts on {result.get('files', [])};"
" resolve by hand, commit(message='...'), then sync_branch again"
)
else:
next_hint = spec_module._INTENT_VERBS["sync_branch"].next_hint(t)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=next_hint,
evidence=evidence,
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _sync_branch_preflight_rejection(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
agent: Any,
role_str: str,
briefing: dict[str, Any],
) -> tuple[Envelope | None, str]:
"""Role + spec-gate + branch/base guards for ``sync_branch``.
Returns ``(rejection_envelope, base_branch)``. When all guards pass the
rejection is ``None`` and ``base_branch`` is the resolved merge target
the handler hands to ``GitService.sync_task_branch``; on any guard
failure the envelope is set and ``base_branch`` is empty. Extracted so
``sync_branch`` stays under the PLR0911 return budget (mirrors
``_open_pr_preflight_rejection``).
"""
if t is None:
return (
Envelope.not_found(message=f"task {task_id} not found"),
"",
)
try:
role = spec_module.Role(role_str)
except ValueError:
return (
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
"",
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
)
decision = spec_module.can_invoke_intent(role, "sync_branch", t, spec_ctx)
if not decision.allowed:
return (
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
"",
)
# The task must carry a branch (claimed/in_progress) — there is nothing
# to sync before the branch is cut, and branchless coordination roots
# carry none.
if not t.branch_name:
return (
Envelope.invalid_state(
message=f"task {task_id} has no branch_name to sync",
remediate="call i_will_work_on(task_id) first to cut a branch",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
"",
)
base_branch = await resolve_parent_branch(t, self.task)
# Defense-in-depth: agents never rebase into a protected/default branch
# or a ``-``-prefixed (shell-injection) ref. A dev task's base is its
# parent (cell-task) branch, so this should never fire — but never let a
# rebase reach master through a branchless-parent fallback.
if base_branch.startswith("-") or base_branch in ("master", "main"):
return (
Envelope.invalid_state(
message=f"resolved base branch '{base_branch}' is protected",
remediate=(
"the task's base resolved to master/main; sync_branch"
" refuses to rebase into a protected branch — escalate"
" via i_am_blocked(reason='...') if your base is wrong"
),
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
"",
)
return None, base_branch
async def i_am_idle(self, agent_id: UUID) -> Envelope:
"""Report no more work. Soft-block if there are unread A2As or @mentions.
+37
View File
@@ -3695,6 +3695,43 @@ class GitService(BaseService):
git_token=git_token,
)
async def sync_task_branch(
self,
task: Any,
*,
base_branch: str,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Rebase a task's branch onto ``base_branch`` through the gate.
Task-keyed twin of :meth:`rebase_pr_for_task`: ``head_branch`` is the
task's own ``branch_name`` and ``base_branch`` is supplied by the caller
(the choreographer resolves it via ``merge_chain.resolve_parent_branch``),
so this works BEFORE a PR exists a developer mid-work whose branch
fell behind its base (a sibling's PR merged into the parent branch) can
rebase through the dev ``sync_branch`` verb instead of the CEO/PM-only
``/rebase`` HTTP route. Mirrors ``rebase_pr_for_task``'s workspace/token
resolution and delegates to :meth:`rebase_onto_base`, returning the same
classification dict (``rebased`` / ``superseded`` / ``conflicts``).
The caller MUST ensure ``base_branch`` is not a protected branch
agents never rebase into master/main; the choreographer guards this.
"""
if not task.branch_name:
raise ValueError("sync_task_branch requires a task with a branch_name")
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
git_token = await self._get_project_token_or_raise(project.slug)
return await self.rebase_onto_base(
workspace,
head_branch=task.branch_name,
base_branch=base_branch,
git_token=git_token,
)
async def close_pull_request(
self,
pr_number: int,
+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"})