mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
refactor(gateway): rename submit_for_qa to open_pr; pin atomic preconditions
Pre-fix, submit_for_qa opened a PR (side effect) and returned OK with next='call i_am_done' — agents read the verb name, assumed they were done with QA handoff, never called i_am_done, and PRs ended up orphaned (PR #12 in the 2026-05-08 trace). Two changes: 1. Rename submit_for_qa -> open_pr so the verb name matches the semantic. The PR opens here; the actual QA handoff happens at i_am_done. Renamed across: - choreographer/_impl.py (method) - mcp/flow_server.py (tool registration + _TOOLS dict) - api/routes/v2/flow_dev.py (route + handler) - api/schemas/v2/flow.py (OpenPrRequest) - services/gateway/verb_gates.py (_STATE_VERBS) - services/gateway/role_config.py (developer flow manifest) - services/gateway/content_actions.py (commit-success next= hint) - agent_sdk/server.py (post-tool guidance map) - runtime/orchestrator.py (developer prompt) - agents/prompts/{base,roles/developer,_generated/*}.md - tests/unit/gateway/test_submit_for_qa.py -> test_open_pr.py - tests/unit/api/routes/v2/test_flow_dev.py - tests/unit/gateway/test_verb_gates.py - tests/unit/api/test_correlation_id.py - tests/unit/mcp_servers/test_flow_server.py - tests/integration/test_full_lifecycle_real_db.py 2. New regression test (test_open_pr_does_not_create_pr_if_no_commits) pins the atomic invariant: preconditions (assignee, commits, no-prior-PR) must be checked BEFORE git.create_pr/push_branch run. Any future re-ordering breaks the test. Tests: 3128 passing (3127 + 1 new), 100% coverage, ruff clean. Note: TaskService.submit_for_qa() (the v1-layer service method) is INTENTIONALLY not renamed — it's a different layer used by the v1 routes. The rename here is only the gateway verb surface.
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `give_me_work` | `give_me_work()` |
|
| `give_me_work` | `give_me_work()` |
|
||||||
| `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None)` |
|
| `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_done` | `i_am_done(task_id: UUID, notes: str = '')` |
|
||||||
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str)` |
|
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str)` |
|
||||||
| `unclaim` | `unclaim(task_id: UUID)` |
|
| `unclaim` | `unclaim(task_id: UUID)` |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ as the source of truth for verb signatures.
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `give_me_work` | `give_me_work()` |
|
| `give_me_work` | `give_me_work()` |
|
||||||
| `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None)` |
|
| `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_done` | `i_am_done(task_id: UUID, notes: str = '')` |
|
||||||
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str)` |
|
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str)` |
|
||||||
| `unclaim` | `unclaim(task_id: UUID)` |
|
| `unclaim` | `unclaim(task_id: UUID)` |
|
||||||
|
|||||||
@@ -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_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 |
|
| `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_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 |
|
| `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 |
|
| `docs_notes>=20` | Documenter notes must be ≥20 chars summarizing what you wrote and where. | i_documented |
|
||||||
| `files` | Call `i_documented` with `files=['<path>', ...]` listing each doc file written. | i_documented |
|
| `files` | Call `i_documented` with `files=['<path>', ...]` listing each doc file written. | i_documented |
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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 `submit_for_qa` first. | At least one commit; PR open; progress entry; journal `reflect`; every acceptance criterion addressed. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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.
|
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.
|
5. `commit(message)` after each meaningful change. Repeat 4-5 until the criteria are met.
|
||||||
6. `note(scope='reflect', text="<what you did + why>")` before submitting.
|
6. `note(scope='reflect', text="<what you did + why>")` before submitting.
|
||||||
7. `submit_for_qa(task_id="<your-task>")` -> 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="<your-task>")` -> 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="<your-task>", notes="<self-verification summary>")` -> 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.
|
8. `i_am_done(task_id="<your-task>", notes="<self-verification summary>")` -> 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`.
|
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
|
## 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.
|
- ❌ 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.
|
- ❌ 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.
|
- ❌ 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.
|
||||||
|
|||||||
+11
-10
@@ -426,22 +426,23 @@ TRACEABILITY_REMINDERS: dict[str, tuple[str, str]] = {
|
|||||||
"journal",
|
"journal",
|
||||||
"Document cancellation reason with roboco_journal_entry()",
|
"Document cancellation reason with roboco_journal_entry()",
|
||||||
),
|
),
|
||||||
# === GIT TOOLS ===
|
# === GIT / WRITE TOOLS ===
|
||||||
"roboco_git_commit": (
|
# 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",
|
"journal",
|
||||||
"Significant change? Capture insights with roboco_journal_learning()",
|
"Significant change? Capture insights with roboco_journal_learning()",
|
||||||
),
|
),
|
||||||
"roboco_git_push": (
|
"open_pr": (
|
||||||
"verify",
|
|
||||||
"Ensure commits describe changes clearly",
|
|
||||||
),
|
|
||||||
"roboco_git_create_pr": (
|
|
||||||
"reflect",
|
"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",
|
||||||
"Verify all CI checks pass before merging",
|
"Verify all CI checks pass and QA + docs signed off before merging",
|
||||||
),
|
),
|
||||||
# === A2A TOOLS ===
|
# === A2A TOOLS ===
|
||||||
"roboco_agent_request": (
|
"roboco_agent_request": (
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from roboco.api.schemas.v2.flow import (
|
|||||||
IAmDoneRequest,
|
IAmDoneRequest,
|
||||||
IAmIdleRequest,
|
IAmIdleRequest,
|
||||||
IWillWorkOnRequest,
|
IWillWorkOnRequest,
|
||||||
|
OpenPrRequest,
|
||||||
ResumeRequest,
|
ResumeRequest,
|
||||||
SubmitForQaRequest,
|
|
||||||
UnclaimRequest,
|
UnclaimRequest,
|
||||||
)
|
)
|
||||||
from roboco.services.gateway.choreographer import Choreographer
|
from roboco.services.gateway.choreographer import Choreographer
|
||||||
@@ -52,14 +52,14 @@ async def i_will_work_on(
|
|||||||
return envelope_to_response(env, request)
|
return envelope_to_response(env, request)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/submit_for_qa")
|
@router.post("/open_pr")
|
||||||
async def submit_for_qa(
|
async def open_pr(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: SubmitForQaRequest,
|
body: OpenPrRequest,
|
||||||
x_agent_id: _AgentIdHeader,
|
x_agent_id: _AgentIdHeader,
|
||||||
choreographer: _ChoreographerDep,
|
choreographer: _ChoreographerDep,
|
||||||
) -> dict:
|
) -> 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)
|
return envelope_to_response(env, request)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class IWillWorkOnRequest(BaseModel):
|
|||||||
plan: str | None = None
|
plan: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SubmitForQaRequest(BaseModel):
|
class OpenPrRequest(BaseModel):
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
return _post(_role_path("i_will_work_on"), {"task_id": task_id, "plan": plan})
|
||||||
|
|
||||||
|
|
||||||
def submit_for_qa(task_id: str) -> dict[str, Any]:
|
def open_pr(task_id: str) -> dict[str, Any]:
|
||||||
"""Push your branch and open a PR. Run after your last commit, before i_am_done."""
|
"""Push your branch and open a PR.
|
||||||
return _post(_role_path("submit_for_qa"), {"task_id": task_id})
|
|
||||||
|
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]:
|
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})
|
return _post(_role_path("i_am_done"), {"task_id": task_id, "notes": notes})
|
||||||
|
|
||||||
|
|
||||||
@@ -264,7 +271,7 @@ _TOOLS: dict[str, Any] = {
|
|||||||
# dev
|
# dev
|
||||||
"give_me_work": give_me_work,
|
"give_me_work": give_me_work,
|
||||||
"i_will_work_on": i_will_work_on,
|
"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_done": i_am_done,
|
||||||
"i_am_blocked": i_am_blocked,
|
"i_am_blocked": i_am_blocked,
|
||||||
"unclaim": unclaim,
|
"unclaim": unclaim,
|
||||||
|
|||||||
@@ -2359,7 +2359,7 @@ class AgentOrchestrator:
|
|||||||
"""True if a `claimed` task is actually in the doc/PR parallel phase.
|
"""True if a `claimed` task is actually in the doc/PR parallel phase.
|
||||||
|
|
||||||
The `original_developer:` quick_context marker is set pre-QA by
|
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
|
awaiting_qa task (wrong) from a doc-claimed awaiting_documentation
|
||||||
task (right). Require the claimant to be a documenter.
|
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.
|
as you make trade-offs.
|
||||||
|
|
||||||
When acceptance criteria are met, call
|
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="<self-verification summary>")
|
then i_am_done(task_id="...", notes="<self-verification summary>")
|
||||||
to submit for QA review.
|
to submit for QA review.
|
||||||
|
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ class Choreographer:
|
|||||||
task_id=str(task_id),
|
task_id=str(task_id),
|
||||||
next=(
|
next=(
|
||||||
"edit + commit(message) for each meaningful change,"
|
"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,
|
context_briefing=briefing,
|
||||||
).with_introspection(task=t, role=role)
|
).with_introspection(task=t, role=role)
|
||||||
@@ -537,19 +537,22 @@ class Choreographer:
|
|||||||
env.context_briefing = briefing
|
env.context_briefing = briefing
|
||||||
return env
|
return env
|
||||||
|
|
||||||
async def submit_for_qa(self, agent_id: UUID, task_id: UUID) -> Envelope:
|
async def open_pr(self, agent_id: UUID, task_id: UUID) -> Envelope:
|
||||||
"""Push the dev's branch and open a PR. Does NOT submit for QA itself —
|
"""Push the dev's branch and open a PR.
|
||||||
the dev calls ``i_am_done`` after this verb returns success.
|
|
||||||
|
|
||||||
Gate E made ``i_am_done`` strict: it requires ``pr_number`` set. The
|
Atomic: validates ALL preconditions (assignee, commits,
|
||||||
catch-up shortcut lives off the dev manifest, so before this verb
|
no-prior-PR) BEFORE running any git side effects. If any check
|
||||||
existed devs had no escape from the NO_PR rejection. ``submit_for_qa``
|
fails, no PR is opened. After success, the dev calls
|
||||||
is the explicit push + open-PR step, leaving ``i_am_done`` to do the
|
``i_am_done`` to actually transition the task to awaiting_qa.
|
||||||
strict submit.
|
|
||||||
|
|
||||||
Pre-flight: caller must own the task, have committed at least once,
|
Renamed from ``submit_for_qa`` (2026-05-08): the old name
|
||||||
and not already have a PR open. If a PR is already open, this verb
|
suggested this verb advanced the lifecycle, but it only opens
|
||||||
is idempotent — it points the dev at ``i_am_done``.
|
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)
|
t = await self.task.get(task_id)
|
||||||
if t is None:
|
if t is None:
|
||||||
@@ -557,7 +560,7 @@ class Choreographer:
|
|||||||
Envelope.not_found(message=f"task {task_id} not found"),
|
Envelope.not_found(message=f"task {task_id} not found"),
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
verb="submit_for_qa",
|
verb="open_pr",
|
||||||
)
|
)
|
||||||
briefing = await self._briefing_for(agent_id, task_id)
|
briefing = await self._briefing_for(agent_id, task_id)
|
||||||
agent = await self.task.agent_for(agent_id)
|
agent = await self.task.agent_for(agent_id)
|
||||||
@@ -571,7 +574,7 @@ class Choreographer:
|
|||||||
).with_introspection(task=t, role=role),
|
).with_introspection(task=t, role=role),
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
verb="submit_for_qa",
|
verb="open_pr",
|
||||||
)
|
)
|
||||||
if not t.commits:
|
if not t.commits:
|
||||||
return await self._emit_rejection(
|
return await self._emit_rejection(
|
||||||
@@ -585,7 +588,7 @@ class Choreographer:
|
|||||||
).with_introspection(task=t, role=role),
|
).with_introspection(task=t, role=role),
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
verb="submit_for_qa",
|
verb="open_pr",
|
||||||
)
|
)
|
||||||
if t.pr_number is not None:
|
if t.pr_number is not None:
|
||||||
return Envelope.ok(
|
return Envelope.ok(
|
||||||
@@ -620,7 +623,7 @@ class Choreographer:
|
|||||||
- tracing: progress entry, journal:reflect, acceptance criteria
|
- tracing: progress entry, journal:reflect, acceptance criteria
|
||||||
- field-level: at least one commit, PR open
|
- field-level: at least one commit, PR open
|
||||||
The dev must have called ``commit()`` (do_server) at least once and
|
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
|
is the dev's explicit attestation that the work is complete; it
|
||||||
auto-runs the in_progress → verifying transition (which seeds
|
auto-runs the in_progress → verifying transition (which seeds
|
||||||
``self_verified``) and then verifying → awaiting_qa.
|
``self_verified``) and then verifying → awaiting_qa.
|
||||||
@@ -723,7 +726,7 @@ class Choreographer:
|
|||||||
if t.pr_number is None:
|
if t.pr_number is None:
|
||||||
missing.append("NO_PR")
|
missing.append("NO_PR")
|
||||||
hints.append(
|
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"
|
" branch and open the PR, then retry i_am_done"
|
||||||
)
|
)
|
||||||
if not missing:
|
if not missing:
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class ContentActions:
|
|||||||
return Envelope.ok(
|
return Envelope.ok(
|
||||||
status=str(t.status),
|
status=str(t.status),
|
||||||
task_id=str(t.id),
|
task_id=str(t.id),
|
||||||
next="continue committing, or submit_for_qa when ready",
|
next="continue committing, or open_pr when ready",
|
||||||
context_briefing={},
|
context_briefing={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class RoleConfig:
|
|||||||
_DEV_FLOW = (
|
_DEV_FLOW = (
|
||||||
"give_me_work",
|
"give_me_work",
|
||||||
"i_will_work_on",
|
"i_will_work_on",
|
||||||
"submit_for_qa",
|
"open_pr",
|
||||||
"i_am_done",
|
"i_am_done",
|
||||||
"i_am_blocked",
|
"i_am_blocked",
|
||||||
"unclaim",
|
"unclaim",
|
||||||
|
|||||||
@@ -42,21 +42,21 @@ _STATE_VERBS: dict[tuple[str, str], tuple[str, ...]] = {
|
|||||||
("developer", "needs_revision"): ("i_will_work_on", "unclaim"),
|
("developer", "needs_revision"): ("i_will_work_on", "unclaim"),
|
||||||
("developer", "claimed"): (
|
("developer", "claimed"): (
|
||||||
"commit",
|
"commit",
|
||||||
"submit_for_qa",
|
"open_pr",
|
||||||
"i_am_done",
|
"i_am_done",
|
||||||
"i_am_blocked",
|
"i_am_blocked",
|
||||||
"unclaim",
|
"unclaim",
|
||||||
),
|
),
|
||||||
("developer", "in_progress"): (
|
("developer", "in_progress"): (
|
||||||
"commit",
|
"commit",
|
||||||
"submit_for_qa",
|
"open_pr",
|
||||||
"i_am_done",
|
"i_am_done",
|
||||||
"i_am_blocked",
|
"i_am_blocked",
|
||||||
"unclaim",
|
"unclaim",
|
||||||
),
|
),
|
||||||
("developer", "verifying"): (
|
("developer", "verifying"): (
|
||||||
"commit",
|
"commit",
|
||||||
"submit_for_qa",
|
"open_pr",
|
||||||
"i_am_done",
|
"i_am_done",
|
||||||
"i_am_blocked",
|
"i_am_blocked",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -322,10 +322,10 @@ async def test_dev_can_claim_pending_task_via_gateway(
|
|||||||
async def test_dev_full_chain_through_awaiting_qa(
|
async def test_dev_full_chain_through_awaiting_qa(
|
||||||
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
||||||
) -> None:
|
) -> 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:
|
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
|
- i_am_done auto-runs submit_verification (P1-3) → verifying → awaiting_qa
|
||||||
- Heartbeat refreshes after each verb (`_touch`)
|
- Heartbeat refreshes after each verb (`_touch`)
|
||||||
- active_claimant_id remains set through dev's tenure
|
- 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
|
# 2. Commit (via stub git directly + record progress on task — the gateway
|
||||||
# path through ContentActions.commit calls task.add_progress, which we
|
# 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(
|
await stub_git.commit(
|
||||||
branch_name=_BRANCH,
|
branch_name=_BRANCH,
|
||||||
message=f"[{str(task.id)[:8]}] feat(api): add /healthz",
|
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")
|
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.
|
# 3. open_pr — push + open PR. After this, task.pr_number is set.
|
||||||
env = await c.submit_for_qa(dev_agent.id, task.id)
|
env = await c.open_pr(dev_agent.id, task.id)
|
||||||
assert env.error is None, f"submit_for_qa failed: {env.message}"
|
assert env.error is None, f"open_pr failed: {env.message}"
|
||||||
refreshed = await task_service.get(task.id)
|
refreshed = await task_service.get(task.id)
|
||||||
assert refreshed is not None
|
assert refreshed is not None
|
||||||
assert refreshed.pr_number == _PR_NUMBER, "P0-7 / S-02: PR recorded on task"
|
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,
|
task_id=task.id,
|
||||||
)
|
)
|
||||||
await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz")
|
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")
|
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
|
||||||
assert env.error is None
|
assert env.error is None
|
||||||
assert env.status == "awaiting_qa"
|
assert env.status == "awaiting_qa"
|
||||||
|
|||||||
@@ -157,20 +157,20 @@ def test_i_am_blocked_rejects_empty_reason() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_submit_for_qa_dispatches_task_id() -> None:
|
async def test_open_pr_dispatches_task_id() -> None:
|
||||||
"""POST submit_for_qa forwards task_id."""
|
"""POST open_pr forwards task_id."""
|
||||||
mock_chore = MagicMock()
|
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)
|
return_value=_make_envelope(status="awaiting_qa", task_id=_TASK_ID)
|
||||||
)
|
)
|
||||||
client = TestClient(_build_app(mock_chore))
|
client = TestClient(_build_app(mock_chore))
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/v2/flow/developer/submit_for_qa",
|
"/api/v2/flow/developer/open_pr",
|
||||||
json={"task_id": _TASK_ID},
|
json={"task_id": _TASK_ID},
|
||||||
headers=_HEADERS,
|
headers=_HEADERS,
|
||||||
)
|
)
|
||||||
assert resp.status_code == _HTTP_200
|
assert resp.status_code == _HTTP_200
|
||||||
mock_chore.submit_for_qa.assert_awaited_once()
|
mock_chore.open_pr.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ def _reload_mcp_module(monkeypatch: pytest.MonkeyPatch, dotted: str) -> ModuleTy
|
|||||||
"flow_tools": [
|
"flow_tools": [
|
||||||
"give_me_work",
|
"give_me_work",
|
||||||
"i_will_work_on",
|
"i_will_work_on",
|
||||||
"submit_for_qa",
|
"open_pr",
|
||||||
"i_am_done",
|
"i_am_done",
|
||||||
"i_am_blocked",
|
"i_am_blocked",
|
||||||
"unclaim",
|
"unclaim",
|
||||||
|
|||||||
@@ -1049,3 +1049,38 @@ async def test_i_will_work_on_envelope_carries_introspection_on_rejection() -> N
|
|||||||
assert isinstance(body["valid_next_verbs"], list)
|
assert isinstance(body["valid_next_verbs"], list)
|
||||||
# Lifecycle verbs are NOT in the list for a completed task.
|
# Lifecycle verbs are NOT in the list for a completed task.
|
||||||
assert "i_will_work_on" not in body["valid_next_verbs"]
|
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()
|
||||||
|
|||||||
@@ -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.
|
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
|
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
|
@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()
|
aid = uuid4()
|
||||||
tid = uuid4()
|
tid = uuid4()
|
||||||
t = MagicMock(
|
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)
|
deps = _make_deps(task=task_svc, git=git_svc, work_session=work_session_svc)
|
||||||
c = Choreographer(deps)
|
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.push_branch.assert_awaited()
|
||||||
git_svc.create_pr.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
|
@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()
|
aid = uuid4()
|
||||||
other = uuid4()
|
other = uuid4()
|
||||||
tid = 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)
|
deps = _make_deps(task=task_svc, git=git_svc)
|
||||||
c = Choreographer(deps)
|
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.push_branch.assert_not_awaited()
|
||||||
git_svc.create_pr.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
|
@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()
|
aid = uuid4()
|
||||||
tid = uuid4()
|
tid = uuid4()
|
||||||
t = MagicMock(
|
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)
|
deps = _make_deps(task=task_svc, git=git_svc)
|
||||||
c = Choreographer(deps)
|
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.push_branch.assert_not_awaited()
|
||||||
git_svc.create_pr.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
|
@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()
|
aid = uuid4()
|
||||||
tid = uuid4()
|
tid = uuid4()
|
||||||
t = MagicMock(
|
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)
|
deps = _make_deps(task=task_svc, git=git_svc)
|
||||||
c = Choreographer(deps)
|
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.push_branch.assert_not_awaited()
|
||||||
git_svc.create_pr.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
|
@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()
|
aid = uuid4()
|
||||||
tid = uuid4()
|
tid = uuid4()
|
||||||
task_svc = AsyncMock()
|
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)
|
deps = _make_deps(task=task_svc)
|
||||||
c = Choreographer(deps)
|
c = Choreographer(deps)
|
||||||
|
|
||||||
env = await c.submit_for_qa(aid, tid)
|
env = await c.open_pr(aid, tid)
|
||||||
|
|
||||||
assert env.error == "not_found"
|
assert env.error == "not_found"
|
||||||
@@ -33,7 +33,7 @@ def test_developer_pending_task_can_claim() -> None:
|
|||||||
def test_developer_in_progress_task_can_commit_and_finish() -> None:
|
def test_developer_in_progress_task_can_commit_and_finish() -> None:
|
||||||
verbs = valid_next_verbs("developer", _task("in_progress"))
|
verbs = valid_next_verbs("developer", _task("in_progress"))
|
||||||
assert "commit" in verbs
|
assert "commit" in verbs
|
||||||
assert "submit_for_qa" in verbs
|
assert "open_pr" in verbs
|
||||||
assert "i_am_done" in verbs
|
assert "i_am_done" in verbs
|
||||||
assert "i_am_blocked" 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.
|
# Idle / observation verbs may still be offered; lifecycle verbs aren't.
|
||||||
assert "i_will_work_on" not in verbs
|
assert "i_will_work_on" not in verbs
|
||||||
assert "commit" 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:
|
def test_unknown_role_returns_empty_list() -> None:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ _FULL_MANIFEST = {
|
|||||||
"flow_tools": [
|
"flow_tools": [
|
||||||
"give_me_work",
|
"give_me_work",
|
||||||
"i_will_work_on",
|
"i_will_work_on",
|
||||||
"submit_for_qa",
|
"open_pr",
|
||||||
"i_am_done",
|
"i_am_done",
|
||||||
"i_am_blocked",
|
"i_am_blocked",
|
||||||
"unclaim",
|
"unclaim",
|
||||||
|
|||||||
Reference in New Issue
Block a user