diff --git a/agents/prompts/_generated/developer.md b/agents/prompts/_generated/developer.md index 2a77b2c3..e0558bde 100644 --- a/agents/prompts/_generated/developer.md +++ b/agents/prompts/_generated/developer.md @@ -9,7 +9,7 @@ |------|-------------| | `give_me_work` | `give_me_work()` | | `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None)` | -| `submit_for_qa` | `submit_for_qa(task_id: UUID)` | +| `open_pr` | `open_pr(task_id: UUID)` | | `i_am_done` | `i_am_done(task_id: UUID, notes: str = '')` | | `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str)` | | `unclaim` | `unclaim(task_id: UUID)` | diff --git a/agents/prompts/_generated/verbs.md b/agents/prompts/_generated/verbs.md index 1693668f..8b86fbe4 100644 --- a/agents/prompts/_generated/verbs.md +++ b/agents/prompts/_generated/verbs.md @@ -15,7 +15,7 @@ as the source of truth for verb signatures. |------|-------------| | `give_me_work` | `give_me_work()` | | `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None)` | -| `submit_for_qa` | `submit_for_qa(task_id: UUID)` | +| `open_pr` | `open_pr(task_id: UUID)` | | `i_am_done` | `i_am_done(task_id: UUID, notes: str = '')` | | `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str)` | | `unclaim` | `unclaim(task_id: UUID)` | diff --git a/agents/prompts/base.md b/agents/prompts/base.md index 9f4ac7dc..333dca0f 100644 --- a/agents/prompts/base.md +++ b/agents/prompts/base.md @@ -38,7 +38,7 @@ Read the `missing` array literally. Each entry below names what to do; the `reme | `qa_notes>=min` | QA `notes` argument must be ≥80 chars; review the diff and write a substantive note. | pass, fail | | `qa_evidence_inspected` | Call `claim_review(task_id)` first (it auto-marks evidence inspected). | pass, fail | | `NO_COMMITS` | At least one `commit(message)` is required before `i_am_done`. | i_am_done | -| `NO_PR` | Call `submit_for_qa(task_id)` to push the branch and open the PR, then retry. | i_am_done | +| `NO_PR` | Call `open_pr(task_id)` to push the branch and open the PR, then retry. | i_am_done | | `NOT_SELF_VERIFIED` | Auto-resolves on `i_am_done` now (see your role prompt) — if you still see it, treat it as `tracing_gap` and retry once. | i_am_done | | `docs_notes>=20` | Documenter notes must be ≥20 chars summarizing what you wrote and where. | i_documented | | `files` | Call `i_documented` with `files=['', ...]` listing each doc file written. | i_documented | diff --git a/agents/prompts/roles/developer.md b/agents/prompts/roles/developer.md index 0106116e..3d4ab7a2 100644 --- a/agents/prompts/roles/developer.md +++ b/agents/prompts/roles/developer.md @@ -20,8 +20,8 @@ You write code; you do not coordinate. If you find yourself thinking "let me als | `give_me_work()` | Returns your highest-priority task or `idle`. | None. | | `i_will_work_on(task_id, plan=None)` | Claims a `pending`/`needs_revision` task; resumes a `claimed`/`in_progress` task you own. Auto-creates branch on first claim. | Task assigned to you (or unassigned and matches your role/team); for `claimed` resumption, plan and branch must exist. | | `commit(message)` | Makes the git commit, auto-prefixes `[task-id]`, records a progress entry. This is the ONLY way to commit — the gateway covers the actual git operation. | Task in `in_progress`; on your branch. | -| `submit_for_qa(task_id)` | Push your branch and open a PR. Run after your last commit, before `i_am_done`. | Task assigned to you; at least one commit; no PR yet. | -| `i_am_done(task_id, notes)` | Submit for QA. Auto-runs in_progress→verifying→awaiting_qa. Requires PR already open — run `submit_for_qa` first. | At least one commit; PR open; progress entry; journal `reflect`; every acceptance criterion addressed. | +| `open_pr(task_id)` | Push your branch and open a PR. Run after your last commit, before `i_am_done`. | Task assigned to you; at least one commit; no PR yet. | +| `i_am_done(task_id, notes)` | Submit for QA. Auto-runs in_progress→verifying→awaiting_qa. Requires PR already open — run `open_pr` first. | At least one commit; PR open; progress entry; journal `reflect`; every acceptance criterion addressed. | | `i_am_blocked(reason)` | Records the blocker, escalates to your PM, idles you. | Task is yours and active. | | `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. | | `resume(task_id)` | Resume a paused task. Transitions paused → in_progress. | Task assigned to you and in paused state. | @@ -38,13 +38,13 @@ You write code; you do not coordinate. If you find yourself thinking "let me als 4. Edit / Write your changes inside the workspace. Run tests via `Bash` if needed. 5. `commit(message)` after each meaningful change. Repeat 4-5 until the criteria are met. 6. `note(scope='reflect', text="")` before submitting. -7. `submit_for_qa(task_id="")` -> pushes your branch and opens the PR up to your cell PM's branch. The response includes the PR number. +7. `open_pr(task_id="")` -> pushes your branch and opens the PR up to your cell PM's branch. The response includes the PR number. 8. `i_am_done(task_id="", notes="")` -> submit for QA against the PR you just opened. Auto-runs the in_progress→verifying→awaiting_qa transitions. Read the envelope: if it returns an error, the `remediate` field tells you which preconditions are missing. 9. After `i_am_done` succeeds you are finished with this task. `i_am_idle()`. Documenter writes docs; PM merges. You will only be respawned on `needs_revision`. ## Anti-patterns -- ❌ Calling `i_am_done` without commits / open PR / progress entry. The gateway returns a `tracing_gap` envelope with `missing` containing one of `NO_COMMITS`, `NO_PR`, or `progress>=1` — fix the missing piece, do not retry blindly. For `NO_PR`, call `submit_for_qa(task_id)` to push and open the PR, then retry `i_am_done`. +- ❌ Calling `i_am_done` without commits / open PR / progress entry. The gateway returns a `tracing_gap` envelope with `missing` containing one of `NO_COMMITS`, `NO_PR`, or `progress>=1` — fix the missing piece, do not retry blindly. For `NO_PR`, call `open_pr(task_id)` to push and open the PR, then retry `i_am_done`. - ❌ Editing files outside your assigned task's branch. Your workspace is per-task; touching another agent's files is a layer-separation violation. - ❌ Trying to merge your own PR. Merging is a PM verb — you have no merge tool. If you call `Bash gh pr merge`, the orchestrator denies it. - ❌ Running `Bash git commit` or `Bash git push`. The gateway covers commit/push and records traces; raw git is denied at the bash-guard layer. diff --git a/roboco/agent_sdk/server.py b/roboco/agent_sdk/server.py index 363e008c..1873b976 100644 --- a/roboco/agent_sdk/server.py +++ b/roboco/agent_sdk/server.py @@ -426,22 +426,23 @@ TRACEABILITY_REMINDERS: dict[str, tuple[str, str]] = { "journal", "Document cancellation reason with roboco_journal_entry()", ), - # === GIT TOOLS === - "roboco_git_commit": ( + # === GIT / WRITE TOOLS === + # Devs/Documenters write code via the roboco-do `commit` verb; + # the choreographer auto-pushes and opens/merges PRs as part of the + # lifecycle transitions. There is no separate roboco_git_commit / + # _push / _create_pr / _merge_pr tool — keys below match what the + # SDK actually receives. + "commit": ( "journal", "Significant change? Capture insights with roboco_journal_learning()", ), - "roboco_git_push": ( - "verify", - "Ensure commits describe changes clearly", - ), - "roboco_git_create_pr": ( + "open_pr": ( "reflect", - "Reflect on all changes with roboco_journal_reflect()", + "PR opens here — reflect on the change with roboco_journal_reflect()", ), - "roboco_git_merge_pr": ( + "complete": ( "verify", - "Verify all CI checks pass before merging", + "Verify all CI checks pass and QA + docs signed off before merging", ), # === A2A TOOLS === "roboco_agent_request": ( diff --git a/roboco/api/routes/v2/flow_dev.py b/roboco/api/routes/v2/flow_dev.py index f36fa690..9a07e568 100644 --- a/roboco/api/routes/v2/flow_dev.py +++ b/roboco/api/routes/v2/flow_dev.py @@ -13,8 +13,8 @@ from roboco.api.schemas.v2.flow import ( IAmDoneRequest, IAmIdleRequest, IWillWorkOnRequest, + OpenPrRequest, ResumeRequest, - SubmitForQaRequest, UnclaimRequest, ) from roboco.services.gateway.choreographer import Choreographer @@ -52,14 +52,14 @@ async def i_will_work_on( return envelope_to_response(env, request) -@router.post("/submit_for_qa") -async def submit_for_qa( +@router.post("/open_pr") +async def open_pr( request: Request, - body: SubmitForQaRequest, + body: OpenPrRequest, x_agent_id: _AgentIdHeader, choreographer: _ChoreographerDep, ) -> dict: - env = await choreographer.submit_for_qa(x_agent_id, body.task_id) + env = await choreographer.open_pr(x_agent_id, body.task_id) return envelope_to_response(env, request) diff --git a/roboco/api/schemas/v2/flow.py b/roboco/api/schemas/v2/flow.py index 3b6de192..568d42b1 100644 --- a/roboco/api/schemas/v2/flow.py +++ b/roboco/api/schemas/v2/flow.py @@ -14,7 +14,7 @@ class IWillWorkOnRequest(BaseModel): plan: str | None = None -class SubmitForQaRequest(BaseModel): +class OpenPrRequest(BaseModel): task_id: UUID diff --git a/roboco/mcp/flow_server.py b/roboco/mcp/flow_server.py index e488f67a..e2f41658 100644 --- a/roboco/mcp/flow_server.py +++ b/roboco/mcp/flow_server.py @@ -118,13 +118,20 @@ def i_will_work_on(task_id: str, plan: str | None = None) -> dict[str, Any]: return _post(_role_path("i_will_work_on"), {"task_id": task_id, "plan": plan}) -def submit_for_qa(task_id: str) -> dict[str, Any]: - """Push your branch and open a PR. Run after your last commit, before i_am_done.""" - return _post(_role_path("submit_for_qa"), {"task_id": task_id}) +def open_pr(task_id: str) -> dict[str, Any]: + """Push your branch and open a PR. + + Atomic: validates ALL preconditions (assignee, commits, no-prior-PR) + BEFORE running any git side effects. After this verb returns success, + call ``i_am_done(task_id, notes='...')`` to actually submit for QA. + Renamed from ``submit_for_qa`` (2026-05-08) — the old name suggested + this verb advanced the lifecycle, but it only opens the PR. + """ + return _post(_role_path("open_pr"), {"task_id": task_id}) def i_am_done(task_id: str, notes: str = "") -> dict[str, Any]: - """Submit for QA. Strict — PR must be open (call submit_for_qa first).""" + """Submit for QA. Strict — PR must be open (call open_pr first).""" return _post(_role_path("i_am_done"), {"task_id": task_id, "notes": notes}) @@ -264,7 +271,7 @@ _TOOLS: dict[str, Any] = { # dev "give_me_work": give_me_work, "i_will_work_on": i_will_work_on, - "submit_for_qa": submit_for_qa, + "open_pr": open_pr, "i_am_done": i_am_done, "i_am_blocked": i_am_blocked, "unclaim": unclaim, diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index bf5ae0c7..e51f3359 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -2359,7 +2359,7 @@ class AgentOrchestrator: """True if a `claimed` task is actually in the doc/PR parallel phase. The `original_developer:` quick_context marker is set pre-QA by - `submit_for_qa`, so it alone cannot distinguish a QA-claimed + `open_pr`, so it alone cannot distinguish a QA-claimed awaiting_qa task (wrong) from a doc-claimed awaiting_documentation task (right). Require the claimant to be a documenter. """ @@ -5163,7 +5163,7 @@ Continue development. Required gates before i_am_done() will succeed as you make trade-offs. When acceptance criteria are met, call -submit_for_qa(task_id="...") to push your branch and open the PR, +open_pr(task_id="...") to push your branch and open the PR, then i_am_done(task_id="...", notes="") to submit for QA review. diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 648da501..caa6a024 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -526,7 +526,7 @@ class Choreographer: task_id=str(task_id), next=( "edit + commit(message) for each meaningful change," - " then submit_for_qa(task_id) and i_am_done(task_id)" + " then open_pr(task_id) and i_am_done(task_id)" ), context_briefing=briefing, ).with_introspection(task=t, role=role) @@ -537,19 +537,22 @@ class Choreographer: env.context_briefing = briefing return env - async def submit_for_qa(self, agent_id: UUID, task_id: UUID) -> Envelope: - """Push the dev's branch and open a PR. Does NOT submit for QA itself — - the dev calls ``i_am_done`` after this verb returns success. + async def open_pr(self, agent_id: UUID, task_id: UUID) -> Envelope: + """Push the dev's branch and open a PR. - Gate E made ``i_am_done`` strict: it requires ``pr_number`` set. The - catch-up shortcut lives off the dev manifest, so before this verb - existed devs had no escape from the NO_PR rejection. ``submit_for_qa`` - is the explicit push + open-PR step, leaving ``i_am_done`` to do the - strict submit. + Atomic: validates ALL preconditions (assignee, commits, + no-prior-PR) BEFORE running any git side effects. If any check + fails, no PR is opened. After success, the dev calls + ``i_am_done`` to actually transition the task to awaiting_qa. - Pre-flight: caller must own the task, have committed at least once, - and not already have a PR open. If a PR is already open, this verb - is idempotent — it points the dev at ``i_am_done``. + Renamed from ``submit_for_qa`` (2026-05-08): the old name + suggested this verb advanced the lifecycle, but it only opens + the PR. Agents misread the name, called it expecting a QA + handoff, then never called i_am_done — orphaning PRs (e.g. + PR #12 in the smoke-test trace). + + Idempotent on re-call: if a PR is already open, returns OK + pointing the dev at ``i_am_done`` without opening another. """ t = await self.task.get(task_id) if t is None: @@ -557,7 +560,7 @@ class Choreographer: Envelope.not_found(message=f"task {task_id} not found"), agent_id=agent_id, task_id=task_id, - verb="submit_for_qa", + verb="open_pr", ) briefing = await self._briefing_for(agent_id, task_id) agent = await self.task.agent_for(agent_id) @@ -571,7 +574,7 @@ class Choreographer: ).with_introspection(task=t, role=role), agent_id=agent_id, task_id=task_id, - verb="submit_for_qa", + verb="open_pr", ) if not t.commits: return await self._emit_rejection( @@ -585,7 +588,7 @@ class Choreographer: ).with_introspection(task=t, role=role), agent_id=agent_id, task_id=task_id, - verb="submit_for_qa", + verb="open_pr", ) if t.pr_number is not None: return Envelope.ok( @@ -620,7 +623,7 @@ class Choreographer: - tracing: progress entry, journal:reflect, acceptance criteria - field-level: at least one commit, PR open The dev must have called ``commit()`` (do_server) at least once and - ``submit_for_qa(task_id)`` to push + open the PR. Calling i_am_done + ``open_pr(task_id)`` to push + open the PR. Calling i_am_done is the dev's explicit attestation that the work is complete; it auto-runs the in_progress → verifying transition (which seeds ``self_verified``) and then verifying → awaiting_qa. @@ -723,7 +726,7 @@ class Choreographer: if t.pr_number is None: missing.append("NO_PR") hints.append( - "no PR open — call submit_for_qa(task_id) to push your" + "no PR open — call open_pr(task_id) to push your" " branch and open the PR, then retry i_am_done" ) if not missing: diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index dea22830..90d3552b 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -169,7 +169,7 @@ class ContentActions: return Envelope.ok( status=str(t.status), task_id=str(t.id), - next="continue committing, or submit_for_qa when ready", + next="continue committing, or open_pr when ready", context_briefing={}, ) diff --git a/roboco/services/gateway/role_config.py b/roboco/services/gateway/role_config.py index f582ac87..b3a33fdd 100644 --- a/roboco/services/gateway/role_config.py +++ b/roboco/services/gateway/role_config.py @@ -25,7 +25,7 @@ class RoleConfig: _DEV_FLOW = ( "give_me_work", "i_will_work_on", - "submit_for_qa", + "open_pr", "i_am_done", "i_am_blocked", "unclaim", diff --git a/roboco/services/gateway/verb_gates.py b/roboco/services/gateway/verb_gates.py index ba32b9e5..b5af97a5 100644 --- a/roboco/services/gateway/verb_gates.py +++ b/roboco/services/gateway/verb_gates.py @@ -42,21 +42,21 @@ _STATE_VERBS: dict[tuple[str, str], tuple[str, ...]] = { ("developer", "needs_revision"): ("i_will_work_on", "unclaim"), ("developer", "claimed"): ( "commit", - "submit_for_qa", + "open_pr", "i_am_done", "i_am_blocked", "unclaim", ), ("developer", "in_progress"): ( "commit", - "submit_for_qa", + "open_pr", "i_am_done", "i_am_blocked", "unclaim", ), ("developer", "verifying"): ( "commit", - "submit_for_qa", + "open_pr", "i_am_done", "i_am_blocked", ), diff --git a/tests/integration/test_full_lifecycle_real_db.py b/tests/integration/test_full_lifecycle_real_db.py index 43cd2b45..05bfdff3 100644 --- a/tests/integration/test_full_lifecycle_real_db.py +++ b/tests/integration/test_full_lifecycle_real_db.py @@ -322,10 +322,10 @@ async def test_dev_can_claim_pending_task_via_gateway( async def test_dev_full_chain_through_awaiting_qa( db_session: AsyncSession, lifecycle_setup: dict[str, Any] ) -> None: - """claim → commit → submit_for_qa → i_am_done lands in awaiting_qa. + """claim → commit → open_pr → i_am_done lands in awaiting_qa. Drives the full developer-side closure path. Verifies: - - submit_for_qa records pr_number on the task (commits + PR pre-flight) + - open_pr records pr_number on the task (commits + PR pre-flight) - i_am_done auto-runs submit_verification (P1-3) → verifying → awaiting_qa - Heartbeat refreshes after each verb (`_touch`) - active_claimant_id remains set through dev's tenure @@ -353,7 +353,7 @@ async def test_dev_full_chain_through_awaiting_qa( # 2. Commit (via stub git directly + record progress on task — the gateway # path through ContentActions.commit calls task.add_progress, which we - # simulate here so submit_for_qa's commits-precondition is satisfied). + # simulate here so open_pr's commits-precondition is satisfied). await stub_git.commit( branch_name=_BRANCH, message=f"[{str(task.id)[:8]}] feat(api): add /healthz", @@ -361,9 +361,9 @@ async def test_dev_full_chain_through_awaiting_qa( ) await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz") - # 3. submit_for_qa — push + open PR. After this, task.pr_number is set. - env = await c.submit_for_qa(dev_agent.id, task.id) - assert env.error is None, f"submit_for_qa failed: {env.message}" + # 3. open_pr — push + open PR. After this, task.pr_number is set. + env = await c.open_pr(dev_agent.id, task.id) + assert env.error is None, f"open_pr failed: {env.message}" refreshed = await task_service.get(task.id) assert refreshed is not None assert refreshed.pr_number == _PR_NUMBER, "P0-7 / S-02: PR recorded on task" @@ -418,7 +418,7 @@ async def test_full_chain_through_doc_handoff( task_id=task.id, ) await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz") - await c.submit_for_qa(dev_agent.id, task.id) + await c.open_pr(dev_agent.id, task.id) env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works") assert env.error is None assert env.status == "awaiting_qa" diff --git a/tests/unit/api/routes/v2/test_flow_dev.py b/tests/unit/api/routes/v2/test_flow_dev.py index 77147dce..a4b73bc0 100644 --- a/tests/unit/api/routes/v2/test_flow_dev.py +++ b/tests/unit/api/routes/v2/test_flow_dev.py @@ -157,20 +157,20 @@ def test_i_am_blocked_rejects_empty_reason() -> None: @pytest.mark.asyncio -async def test_submit_for_qa_dispatches_task_id() -> None: - """POST submit_for_qa forwards task_id.""" +async def test_open_pr_dispatches_task_id() -> None: + """POST open_pr forwards task_id.""" mock_chore = MagicMock() - mock_chore.submit_for_qa = AsyncMock( + mock_chore.open_pr = AsyncMock( return_value=_make_envelope(status="awaiting_qa", task_id=_TASK_ID) ) client = TestClient(_build_app(mock_chore)) resp = client.post( - "/api/v2/flow/developer/submit_for_qa", + "/api/v2/flow/developer/open_pr", json={"task_id": _TASK_ID}, headers=_HEADERS, ) assert resp.status_code == _HTTP_200 - mock_chore.submit_for_qa.assert_awaited_once() + mock_chore.open_pr.assert_awaited_once() @pytest.mark.asyncio diff --git a/tests/unit/api/test_correlation_id.py b/tests/unit/api/test_correlation_id.py index f8232ec6..ce4d28a8 100644 --- a/tests/unit/api/test_correlation_id.py +++ b/tests/unit/api/test_correlation_id.py @@ -155,7 +155,7 @@ def _reload_mcp_module(monkeypatch: pytest.MonkeyPatch, dotted: str) -> ModuleTy "flow_tools": [ "give_me_work", "i_will_work_on", - "submit_for_qa", + "open_pr", "i_am_done", "i_am_blocked", "unclaim", diff --git a/tests/unit/gateway/test_choreographer_impl_branches.py b/tests/unit/gateway/test_choreographer_impl_branches.py index 602981e6..83d1394d 100644 --- a/tests/unit/gateway/test_choreographer_impl_branches.py +++ b/tests/unit/gateway/test_choreographer_impl_branches.py @@ -1049,3 +1049,38 @@ async def test_i_will_work_on_envelope_carries_introspection_on_rejection() -> N assert isinstance(body["valid_next_verbs"], list) # Lifecycle verbs are NOT in the list for a completed task. assert "i_will_work_on" not in body["valid_next_verbs"] + + +@pytest.mark.asyncio +async def test_open_pr_does_not_create_pr_if_no_commits() -> None: + """Atomic invariant: if commits[] is empty, open_pr must NOT call + git.create_pr. Pre-fix this was already true at the verb level, but + this test pins it as a regression: any future refactor that + re-orders precondition vs side effect breaks the test.""" + dev_id = uuid4() + task_id = uuid4() + task = MagicMock( + status="in_progress", + assigned_to=dev_id, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + id=task_id, + title="t", + team="backend", + task_type="code", + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") + git_svc = AsyncMock() + git_svc.create_pr = AsyncMock() + git_svc.push_branch = AsyncMock() + deps = _make_deps(task=task_svc, git=git_svc) + c = Choreographer(deps) + env = await c.open_pr(dev_id, task_id) + body = env.as_dict() + assert body["error"] == "invalid_state" + assert "no commits" in body["message"] + git_svc.create_pr.assert_not_called() + git_svc.push_branch.assert_not_called() diff --git a/tests/unit/gateway/test_submit_for_qa.py b/tests/unit/gateway/test_open_pr.py similarity index 88% rename from tests/unit/gateway/test_submit_for_qa.py rename to tests/unit/gateway/test_open_pr.py index ccc25da3..dd4e79f7 100644 --- a/tests/unit/gateway/test_submit_for_qa.py +++ b/tests/unit/gateway/test_open_pr.py @@ -1,4 +1,4 @@ -"""submit_for_qa pushes the current branch and opens a PR. +"""open_pr pushes the current branch and opens a PR. Gate E (commit c5c2016) made `i_am_done` strict — requires `pr_number` set. The catch-up flow is off the dev manifest. Devs need an explicit verb that @@ -44,7 +44,7 @@ def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps: @pytest.mark.asyncio -async def test_submit_for_qa_pushes_and_opens_pr() -> None: +async def test_open_pr_pushes_and_opens_pr() -> None: aid = uuid4() tid = uuid4() t = MagicMock( @@ -69,7 +69,7 @@ async def test_submit_for_qa_pushes_and_opens_pr() -> None: deps = _make_deps(task=task_svc, git=git_svc, work_session=work_session_svc) c = Choreographer(deps) - env = await c.submit_for_qa(aid, tid) + env = await c.open_pr(aid, tid) git_svc.push_branch.assert_awaited() git_svc.create_pr.assert_awaited() @@ -79,7 +79,7 @@ async def test_submit_for_qa_pushes_and_opens_pr() -> None: @pytest.mark.asyncio -async def test_submit_for_qa_rejects_when_not_assigned() -> None: +async def test_open_pr_rejects_when_not_assigned() -> None: aid = uuid4() other = uuid4() tid = uuid4() @@ -98,7 +98,7 @@ async def test_submit_for_qa_rejects_when_not_assigned() -> None: deps = _make_deps(task=task_svc, git=git_svc) c = Choreographer(deps) - env = await c.submit_for_qa(aid, tid) + env = await c.open_pr(aid, tid) git_svc.push_branch.assert_not_awaited() git_svc.create_pr.assert_not_awaited() @@ -106,7 +106,7 @@ async def test_submit_for_qa_rejects_when_not_assigned() -> None: @pytest.mark.asyncio -async def test_submit_for_qa_rejects_when_no_commits() -> None: +async def test_open_pr_rejects_when_no_commits() -> None: aid = uuid4() tid = uuid4() t = MagicMock( @@ -124,7 +124,7 @@ async def test_submit_for_qa_rejects_when_no_commits() -> None: deps = _make_deps(task=task_svc, git=git_svc) c = Choreographer(deps) - env = await c.submit_for_qa(aid, tid) + env = await c.open_pr(aid, tid) git_svc.push_branch.assert_not_awaited() git_svc.create_pr.assert_not_awaited() @@ -134,7 +134,7 @@ async def test_submit_for_qa_rejects_when_no_commits() -> None: @pytest.mark.asyncio -async def test_submit_for_qa_idempotent_when_pr_already_open() -> None: +async def test_open_pr_idempotent_when_pr_already_open() -> None: aid = uuid4() tid = uuid4() t = MagicMock( @@ -152,7 +152,7 @@ async def test_submit_for_qa_idempotent_when_pr_already_open() -> None: deps = _make_deps(task=task_svc, git=git_svc) c = Choreographer(deps) - env = await c.submit_for_qa(aid, tid) + env = await c.open_pr(aid, tid) git_svc.push_branch.assert_not_awaited() git_svc.create_pr.assert_not_awaited() @@ -162,7 +162,7 @@ async def test_submit_for_qa_idempotent_when_pr_already_open() -> None: @pytest.mark.asyncio -async def test_submit_for_qa_returns_not_found_for_unknown_task() -> None: +async def test_open_pr_returns_not_found_for_unknown_task() -> None: aid = uuid4() tid = uuid4() task_svc = AsyncMock() @@ -170,6 +170,6 @@ async def test_submit_for_qa_returns_not_found_for_unknown_task() -> None: deps = _make_deps(task=task_svc) c = Choreographer(deps) - env = await c.submit_for_qa(aid, tid) + env = await c.open_pr(aid, tid) assert env.error == "not_found" diff --git a/tests/unit/gateway/test_verb_gates.py b/tests/unit/gateway/test_verb_gates.py index 11587909..5675e827 100644 --- a/tests/unit/gateway/test_verb_gates.py +++ b/tests/unit/gateway/test_verb_gates.py @@ -33,7 +33,7 @@ def test_developer_pending_task_can_claim() -> None: def test_developer_in_progress_task_can_commit_and_finish() -> None: verbs = valid_next_verbs("developer", _task("in_progress")) assert "commit" in verbs - assert "submit_for_qa" in verbs + assert "open_pr" in verbs assert "i_am_done" in verbs assert "i_am_blocked" in verbs @@ -140,7 +140,7 @@ def test_completed_task_offers_no_lifecycle_verbs() -> None: # Idle / observation verbs may still be offered; lifecycle verbs aren't. assert "i_will_work_on" not in verbs assert "commit" not in verbs - assert "submit_for_qa" not in verbs + assert "open_pr" not in verbs def test_unknown_role_returns_empty_list() -> None: diff --git a/tests/unit/mcp_servers/test_flow_server.py b/tests/unit/mcp_servers/test_flow_server.py index 06d485be..67c87cce 100644 --- a/tests/unit/mcp_servers/test_flow_server.py +++ b/tests/unit/mcp_servers/test_flow_server.py @@ -24,7 +24,7 @@ _FULL_MANIFEST = { "flow_tools": [ "give_me_work", "i_will_work_on", - "submit_for_qa", + "open_pr", "i_am_done", "i_am_blocked", "unclaim",