fix(gateway): unblock task claim; full Phase 0/1/2 remediation

Resolves the 100% claim-failure rate introduced by the gateway rewrite
  (commit 62bda0c plus 78 follow-ups). Live smoke runs hit
  `404 /api/v2/flow/developer/...` on every dev verb plus a manifest
  fallback that silently exposed off-role verbs to PMs — confirmed
  firing simultaneously in NAS agent logs (be-dev-1, be-pm, main-pm).

  Audit reports under docs/internal/audit_2026_05_04/ catalogue 49
  defects across gateway, services, prompts, MCP transport, substrate,
  and tests (8 detail reports + master synthesis). Six smoking guns;
  three proven in production logs.

  Phase 0 — unblock claim:
  - URL prefix /api/v2/flow/dev → /developer; slug-map board roles
    (product_owner, head_marketing) → /board (D-01)
  - _i_will_work_on AttributeError on None across pending /
    needs_revision / claimed re-entry branches (D-02)
  - Seed last_heartbeat_at in _qa_or_doc_claim (D-03)
  - Drop misleading i_have_committed verb; dev flow uses commit() (D-04)
  - Manifest mount via compose; flow_server + do_server fail loud
    instead of exposing all-verbs fallback (D-12)
  - MCP _post() surfaces envelope body on 4xx so agents see remediate
    hints (D-13)
    on git failure so retries aren't blocked by half-state (S-01)

  Phase 1 — lifecycle stability:
  - _resolve_skill falls back to AgentTable.capabilities (D-06)
  - main_pm_complete uses kwargs for escalate_to_ceo (D-07)
  - i_am_done auto-runs submit_verification when in_progress (D-08)
  - active_claimant_id wired in claim/unclaim paths — single-claimant
    invariant now functional (D-05)
  - qa_pass/qa_fail assert claimed_by parity with qa_agent_id (D-18)
  - Prompt-drift sweep: fail() shape, i_am_done(task_id, notes),
    subtask cap (12 hard / 8 soft), error-code symbology rewritten in
    base.md + per-role anti-patterns (D-10/11/29/30/31, D-37)

  Phase 2 — invariants + architecture:
  - Real-DB integration test exercising claim → in_progress → commit
    → submit_for_qa → i_am_done → awaiting_qa (P2-1)
  - choreographer.py → package; 3 of 6 role mixins extracted
    (board, doc, qa). _impl.py 2,526 → 2,080 lines (-18%). Continuation
    plan in docs/internal/audit_2026_05_04/p2_2_decompose_plan.md (P2-2)
  - Closure guards consolidated via _subtasks_not_terminal_envelope (P2-3)
  - TaskService.unclaim_for_reaper routed through canonical
    _validate_and_set_status; in_progress → pending added to
    VALID_TRANSITIONS (P2-4)
  - Dead code removed: i_am_done_with_catchup verb, _run_catch_up helper
    (P2-5)
  - 6 state-machine invariants asserted via property test (P2-6)
  - attempt_id (uuid4) stamped on every gateway.rejected audit row (P2-7)
  - _reconcile_orphan_claims_on_startup rolls back tasks left CLAIMED
    with branch_name=NULL from prior crashes (P2-8)
  - scripts/regenerate_verb_tables.py introspects Pydantic schemas +
    role_config; compose_prompt injects per-role tables as a layer.
    Eliminates the prompt-drift class structurally (P2-9)

  Other:
  - D-48: orchestrator mounts host's ~/.claude.json when present so
    agents don't boot from backup recovery on every spawn
  - D-49: dev dispatcher rejects role-mismatched spawns (e.g. doc task
    assigned to dev agent)

  Tests: 553 pass · ruff + mypy clean. Live NAS smoke verification
  pending — needs the stack brought back up.
This commit is contained in:
Renn F
2026-05-04 23:43:55 +02:00
parent 0a3c963923
commit 4829f93a68
55 changed files with 2470 additions and 1279 deletions
+1 -1
View File
@@ -343,7 +343,7 @@ read-only into the agent container.
| Role | Flow verbs |
|---------------|--------------------------------------------------------------------------------------------------|
| developer | `give_me_work`, `i_will_work_on`, `i_have_committed`, `i_am_done`, `i_am_blocked` |
| developer | `give_me_work`, `i_will_work_on`, `submit_for_qa`, `i_am_done`, `i_am_blocked` |
| qa | `claim_review`, `pass`, `fail` |
| documenter | `claim_doc_task`, `i_documented` |
| cell_pm | `triage`, `unblock`, `complete`, `escalate_up` |
+18
View File
@@ -0,0 +1,18 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Per-role verb signatures, derived from Pydantic schemas. -->
## Verbs available to you (autogenerated source of truth)
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `triage` | `triage()` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
+30
View File
@@ -0,0 +1,30 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Per-role verb signatures, derived from Pydantic schemas. -->
## Verbs available to you (autogenerated source of truth)
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `give_me_work` | `give_me_work()` |
| `i_will_plan` | `i_will_plan(task_id: UUID, plan: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str = 'code', acceptance_criteria: list[str] | None = None, estimated_complexity: str = 'medium')` |
| `submit_up` | `submit_up(task_id: UUID, notes: str)` |
| `triage` | `triage()` |
| `unblock` | `unblock(task_id: UUID, restore: bool = True)` |
| `complete` | `complete(task_id: UUID, notes: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `unclaim` | `unclaim(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
+27
View File
@@ -0,0 +1,27 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Per-role verb signatures, derived from Pydantic schemas. -->
## Verbs available to you (autogenerated source of truth)
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `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)` |
| `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)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `commit` | `commit(message: str, files: list[str] | None = None)` |
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
+25
View File
@@ -0,0 +1,25 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Per-role verb signatures, derived from Pydantic schemas. -->
## Verbs available to you (autogenerated source of truth)
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `give_me_work` | `give_me_work()` |
| `claim_doc_task` | `claim_doc_task(task_id: UUID)` |
| `i_documented` | `i_documented(task_id: UUID, notes: str, files: list[str])` |
| `unclaim` | `unclaim(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `commit` | `commit(message: str, files: list[str] | None = None)` |
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
@@ -0,0 +1,22 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Per-role verb signatures, derived from Pydantic schemas. -->
## Verbs available to you (autogenerated source of truth)
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `triage` | `triage()` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
+30
View File
@@ -0,0 +1,30 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Per-role verb signatures, derived from Pydantic schemas. -->
## Verbs available to you (autogenerated source of truth)
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `give_me_work` | `give_me_work()` |
| `i_will_plan` | `i_will_plan(task_id: UUID, plan: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str = 'code', acceptance_criteria: list[str] | None = None, estimated_complexity: str = 'medium')` |
| `triage_all` | `triage_all()` |
| `unblock` | `unblock(task_id: UUID, restore: bool = True)` |
| `complete` | `complete(task_id: UUID, notes: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `unclaim` | `unclaim(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
@@ -0,0 +1,22 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Per-role verb signatures, derived from Pydantic schemas. -->
## Verbs available to you (autogenerated source of truth)
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `triage` | `triage()` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
+25
View File
@@ -0,0 +1,25 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Per-role verb signatures, derived from Pydantic schemas. -->
## Verbs available to you (autogenerated source of truth)
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `give_me_work` | `give_me_work()` |
| `claim_review` | `claim_review(task_id: UUID)` |
| `pass` | `pass(task_id: UUID, notes: str)` |
| `fail` | `fail(task_id: UUID, issues: list[str])` |
| `unclaim` | `unclaim(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
+192
View File
@@ -0,0 +1,192 @@
<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->
<!-- Source: services/gateway/role_config.py + api/schemas/v2/. -->
# Per-role verb shapes (autogenerated)
Run `uv run python scripts/regenerate_verb_tables.py` after changing
any role config or schema. Role prompts reference this file's sections
as the source of truth for verb signatures.
## developer
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `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)` |
| `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)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `commit` | `commit(message: str, files: list[str] | None = None)` |
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
## qa
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `give_me_work` | `give_me_work()` |
| `claim_review` | `claim_review(task_id: UUID)` |
| `pass` | `pass(task_id: UUID, notes: str)` |
| `fail` | `fail(task_id: UUID, issues: list[str])` |
| `unclaim` | `unclaim(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
## documenter
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `give_me_work` | `give_me_work()` |
| `claim_doc_task` | `claim_doc_task(task_id: UUID)` |
| `i_documented` | `i_documented(task_id: UUID, notes: str, files: list[str])` |
| `unclaim` | `unclaim(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `commit` | `commit(message: str, files: list[str] | None = None)` |
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
## cell_pm
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `give_me_work` | `give_me_work()` |
| `i_will_plan` | `i_will_plan(task_id: UUID, plan: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str = 'code', acceptance_criteria: list[str] | None = None, estimated_complexity: str = 'medium')` |
| `submit_up` | `submit_up(task_id: UUID, notes: str)` |
| `triage` | `triage()` |
| `unblock` | `unblock(task_id: UUID, restore: bool = True)` |
| `complete` | `complete(task_id: UUID, notes: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `unclaim` | `unclaim(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
## main_pm
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `give_me_work` | `give_me_work()` |
| `i_will_plan` | `i_will_plan(task_id: UUID, plan: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str = 'code', acceptance_criteria: list[str] | None = None, estimated_complexity: str = 'medium')` |
| `triage_all` | `triage_all()` |
| `unblock` | `unblock(task_id: UUID, restore: bool = True)` |
| `complete` | `complete(task_id: UUID, notes: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `unclaim` | `unclaim(task_id: UUID)` |
| `resume` | `resume(task_id: UUID)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
## product_owner
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `triage` | `triage()` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
## head_marketing
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `triage` | `triage()` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
## auditor
### Flow verbs
| Verb | Body schema |
|------|-------------|
| `triage` | `triage()` |
| `i_am_idle` | `i_am_idle()` |
### Content (do) tools
| Tool | Body schema |
|------|-------------|
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None)` |
| `evidence` | `evidence(task_id: UUID)` |
+8 -1
View File
@@ -15,7 +15,14 @@ Every verb returns a JSON envelope. There are exactly two shapes:
- **Success**: `{status, task_id, next, evidence?, context_briefing}` — the `next` field tells you what to call next. Trust it; don't guess.
- **Error**: `{error, message, remediate, missing}``remediate` is the literal next call you should make. `missing` lists the fields you still owe. Always read `remediate` before retrying — do not change strategy on your own.
Examples of error codes you should expect: `PARENT_NOT_CLAIMED`, `SUBTASK_CAP`, `PM_CANNOT_EXECUTE_CODE`, `ALREADY_ACTIVE`, `PAUSED_TASKS_EXIST`, `SEQUENCE_ORDER_VIOLATION`, `SUBTASKS_NOT_TERMINAL`, `NOT_SELF_VERIFIED`, `NO_COMMITS`, `NO_PR`, `NO_PROGRESS`. These are the system catching a lifecycle violation early — the fix is always in `remediate`, never in working around the gate.
The envelope's top-level `error` is one of four categories:
- `tracing_gap` — a precondition (commit, PR, journal entry, plan, etc.) is missing. Look at `missing` for the literal field key. Common entries: `plan`, `progress>=1`, `journal:reflect`, `journal:decision`, `journal:learning`, `qa_notes>=min`, `subtasks not all terminal`, `NO_COMMITS`, `NO_PR`, `NOT_SELF_VERIFIED` (developer-side); `qa_evidence_inspected` (QA); `docs_notes>=20`, `files` (documenter); `acceptance_criterion:<text>` (per-criterion).
- `invalid_state` — task is in a status that doesn't allow this verb (e.g. cannot `start` a `cancelled` task). The `message` names the actual status.
- `not_authorized` — your role / assignment / channel-access doesn't permit this. The `message` names the rule (e.g. "not assigned to you", "role 'cell_pm' may not commit code").
- `not_found` — task / agent / channel id doesn't exist.
The fix is always in `remediate`, never in working around the gate.
## Channels
+4 -4
View File
@@ -47,12 +47,12 @@ You merge what your developers submit (leaf PRs into your cell branch via `compl
## Anti-patterns
- ❌ Creating > 8 subtasks per parent. Consolidate; if you genuinely need more, the work is too big for a single cell-PM scope — split your parent into two parents. The gateway rejects with `SUBTASK_CAP`.
- ❌ Calling `delegate` before `i_will_plan`. The gateway will reject with `PARENT_NOT_CLAIMED` because the parent must be in `in_progress` and claimed by you.
- ❌ Creating > 12 subtasks per parent (the hard cap). Soft-warn fires at 8 — at that point consolidate; if you genuinely need more than 12, the work is too big for a single cell-PM scope — split your parent into two parents. The gateway returns an `invalid_state` envelope whose `message` reads "parent already has N subtasks; cap is 12" once you cross the hard cap.
- ❌ Calling `delegate` before `i_will_plan`. The gateway returns an `invalid_state` envelope whose `message` reads "parent task <id> is in pending; must be in_progress to accept subtasks" — `remediate` tells you to call `i_will_plan` first.
- ❌ Running `Bash git ...` or `Bash curl http://orchestrator/...`. You have no commit verb; the gateway covers everything you need (`complete` merges, `submit_up` opens the cell PR). Raw git/curl is denied at the bash-guard layer.
- ❌ Trying to claim a code task yourself. The gateway will reject with `PM_CANNOT_EXECUTE_CODE`. Decompose and `delegate` instead.
- ❌ Trying to claim a code task yourself. The gateway returns a `not_authorized` envelope whose `message` reads "Cell PM cannot claim code tasks. PMs coordinate, never execute code." Decompose and `delegate` instead.
- ❌ Calling `i_am_idle` while you have a task you never claimed. The gateway will reject — claim or escalate first.
- ❌ Calling `complete` on a parent task whose subtasks aren't all terminal. The gateway will reject with `SUBTASKS_NOT_TERMINAL`. Wait for the closure dispatcher to bring you back.
- ❌ Calling `complete` on a parent task whose subtasks aren't all terminal. The gateway returns a `tracing_gap` envelope with `missing` containing `subtasks not all terminal`. Wait for the closure dispatcher to bring you back.
- ❌ Assigning a subtask to another cell's developer or to Main PM. Subtasks must go to a dev slug in YOUR cell. The gateway rejects cross-cell delegation chains.
- ❌ Calling `i_will_work_on` (that's a developer verb). Yours is `i_will_plan`.
+5 -6
View File
@@ -19,10 +19,9 @@ 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)` | Auto-prefixes `[task-id]`; records a progress entry. | Task in `in_progress`; on your branch. |
| `i_have_committed(message)` | Quick alias for `commit()`. | Same as `commit`. |
| `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(notes)` | Strict submit for QA. Requires PR already open — run `submit_for_qa` first. | Self-verified; 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 `submit_for_qa` 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. |
@@ -40,17 +39,17 @@ You write code; you do not coordinate. If you find yourself thinking "let me als
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.
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.
8. `i_am_done(notes)` -> strict submit for QA against the PR you just opened. 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`.
## Anti-patterns
- ❌ Calling `i_am_done` without commits / open PR / self-verify / progress entry. The gateway will reject with `NO_COMMITS`, `NO_PR`, `NOT_SELF_VERIFIED`, or `NO_PROGRESS` — 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 `submit_for_qa(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.
- ❌ Spawning subagents to do your task for you. Subagents are for parallel research (read multiple files at once), not for executing your work.
- ❌ Claiming a task that isn't yours, or one whose `sequence` says an earlier sibling must finish first. The gateway will reject with `ALREADY_ACTIVE`, `PAUSED_TASKS_EXIST`, or `SEQUENCE_ORDER_VIOLATION`.
- ❌ Claiming a task that isn't yours, or one whose `sequence` says an earlier sibling must finish first. The gateway rejects with an `invalid_state` envelope whose `message` reads "You have a {status} task ({id}); finish or pause it before claiming new work." (already-active claim), "You have N paused task(s); resume before claiming new work." (paused-tasks-exist), or "sequence N blocked: earlier sibling X (sequence M) is in <status>" (sibling-sequence violation). Read the `message` literally — pattern-matching against the prior code names won't work.
- ❌ Doing "while I'm here" cleanup that isn't in the acceptance criteria. Open a separate task; do not silently widen scope.
## When the gateway returns an error
+2 -2
View File
@@ -43,8 +43,8 @@ You do NOT re-implement the developer's work. You do NOT review or critique the
- ❌ Re-implementing the dev's work. You write documentation about the change; you do not change the code. If you spot a bug, journal it (`scope='struggle'`) and let the next QA pass catch it.
- ❌ Documenting before reading the actual PR diff. Call `claim_doc_task` (which returns the diff) or `evidence(task_id)` first. Documenting from the task description alone produces drift.
- ❌ Running `Bash git push` or `Bash git commit`. The gateway covers commit/push; raw git is denied.
- ❌ Documenting your own dev work. The gateway rejects with `SELF_DOC_FORBIDDEN` if you were the original developer.
- ❌ Calling `i_documented` with `files=[]` or notes < 20 chars. Server-side gate rejects with `NO_DOC_FILES` / `DOC_NOTES_REQUIRED`.
- ❌ Documenting your own dev work. Convention only — escalate so a different documenter picks it up. (Self-doc enforcement is best-effort at the gateway today.)
- ❌ Calling `i_documented` with `files=[]` or notes < 20 chars. The gateway returns a `tracing_gap` envelope with `missing` containing `files` and/or `docs_notes>=20`.
- ❌ Treating journal entries as documentation. Journals are private reflection; documentation is the artifact that ships in the PR.
## When the gateway returns an error
+4 -4
View File
@@ -48,12 +48,12 @@ You merge what your Cell PMs submit (cell PRs into your root branch via `complet
## Anti-patterns
- ❌ Assigning a code subtask directly to a developer slug. Always to a Cell PM. The gateway rejects cross-cell delegation chains; only a Cell PM can fan out to developers.
- ❌ Creating > 8 subtasks under a single root. One subtask per cell that needs work; rarely should a root touch more than three cells. The gateway rejects with `SUBTASK_CAP`.
- ❌ Calling `delegate` before `i_will_plan`. The gateway rejects with `PARENT_NOT_CLAIMED`.
- ❌ Creating > 12 subtasks under a single root. One subtask per cell that needs work; rarely should a root touch more than three cells. The gateway returns an `invalid_state` envelope whose `message` reads "parent already has N subtasks; cap is 12" past the hard cap.
- ❌ Calling `delegate` before `i_will_plan`. The gateway returns an `invalid_state` envelope whose `message` reads "parent task <id> is in pending; must be in_progress to accept subtasks" — `remediate` tells you to call `i_will_plan` first.
- ❌ Running `Bash git ...` or `Bash curl http://orchestrator/...`. You have no commit verb; `complete` and `escalate_to_ceo` cover everything you need. Raw git/curl is denied at the bash-guard layer.
- ❌ Trying to claim a code task yourself. The gateway rejects with `PM_CANNOT_EXECUTE_CODE`. If a code task lands on you by mistake, escalate.
- ❌ Trying to claim a code task yourself. The gateway returns a `not_authorized` envelope whose `message` reads "Main PM cannot claim code tasks. PMs coordinate, never execute code." If a code task lands on you by mistake, escalate.
- ❌ Calling `i_am_idle` while you have a task you never claimed. The gateway rejects — claim or escalate first.
- ❌ Calling `complete` on the root before all cell-PM subtasks are terminal. The gateway rejects with `SUBTASKS_NOT_TERMINAL`.
- ❌ Calling `complete` on the root before all cell-PM subtasks are terminal. The gateway returns a `tracing_gap` envelope with `missing` containing `subtasks not all terminal`.
- ❌ Trying to merge to master yourself. Only the CEO does that. Your `complete` on the root opens the master PR and stops at `awaiting_ceo_approval`.
- ❌ Calling `i_will_work_on` (that's a developer verb). Yours is `i_will_plan`.
+3 -3
View File
@@ -36,7 +36,7 @@ A pass without evidence is a betrayal of your role: the entire downstream chain
5. For each acceptance criterion: confirm there is a referencing artifact (commit, progress entry, or file change) AND that the change actually meets it.
6. Run tests/lint via `Bash` if your role permits; otherwise rely on the diff.
7. `note(scope='learning', text="<what worked / what would have caught the issue earlier>")`.
8. Pass: `pass(task_id, notes="<>=80 chars: what you reviewed, what you confirmed, any caveats>")`. Fail: `fail(task_id, issues=[{criterion, file, line, expected, actual}, ...])`.
8. Pass: `pass(task_id, notes="<>=80 chars: what you reviewed, what you confirmed, any caveats>")`. Fail: `fail(task_id, issues=["<concrete actionable issue>", "<another>", ...])` — each issue is a single string. Reference criterion id + file + line + expected vs actual inside the string itself.
## Anti-patterns
@@ -44,8 +44,8 @@ A pass without evidence is a betrayal of your role: the entire downstream chain
- ❌ Approving without reading the diff. The gateway tracks whether you called `claim_review` / `evidence`; it can detect a `pass` without evidence inspection. Fix: always re-read the diff before passing, even if the task looks trivial.
- ❌ Running `Bash git diff` or `Bash gh pr view` to inspect changes. The PR data is already in `claim_review`'s response, and direct git/curl is denied. Call `evidence(task_id)` if you need more.
- ❌ Trying to fix the issue yourself by editing files. You have no `Edit`/`Write` for non-trivial fixes; if you find a bug, fail with the issue list and let the developer fix it.
- ❌ Reviewing your own work. The gateway rejects with `SELF_REVIEW_FORBIDDEN` if you were the original developer. If this happens, escalate so a different QA picks it up.
- ❌ Passing with `notes` < 80 chars. Server-side gate rejects with `QA_NOTES_REQUIRED`.
- ❌ Reviewing your own work. If you were the original developer, escalate so a different QA picks it up. (Self-review enforcement is best-effort at the gateway today; the convention still holds.)
- ❌ Passing with `notes` < 80 chars. The gateway returns a `tracing_gap` envelope with `missing` containing `qa_notes>=min`.
- ❌ Skipping the `journal:learning` entry. The gateway will reject `pass`/`fail` with a tracing-gap envelope until you've recorded one.
## When the gateway returns an error
+5
View File
@@ -248,6 +248,11 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Per-agent SessionStart briefings (pre-rendered task context)
- ${ROBOCO_DATA_DIR:-./data}/briefings:/app/briefings
# Per-agent spawn manifests (role-scoped tool list) — written by the
# orchestrator, bind-mounted into each agent container as
# /app/tool-manifest.json. Without this mount the file is invisible
# to the Docker daemon and agents fall back to all-verbs registration.
- ${ROBOCO_DATA_DIR:-./data}/manifests:/app/manifests
# Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Persistent logs — survive `docker compose down/up`. Orchestrator and
+18 -2
View File
@@ -91,6 +91,20 @@ def _team_layer(prompts_path: Path, team: "Team | None") -> str | None:
return _load_layer(prompts_path / "teams" / team_file)
def _autogen_verbs_layer(prompts_path: Path, role: "AgentRole") -> str | None:
"""Load the autogenerated verb signatures for this role.
The file at ``_generated/<role>.md`` is rebuilt by
``scripts/regenerate_verb_tables.py`` from the Pydantic schemas in
``roboco/api/schemas/v2/`` plus the role-config. Including it as a
composition layer means the prompt always shows the literal accepted
body shape for every verb the agent has eliminating the prompt
drift class identified in audit P2-9 (D-04, D-10, D-11, D-29-D-31).
"""
role_value = role.value if hasattr(role, "value") else str(role)
return _load_layer(prompts_path / "_generated" / f"{role_value}.md")
def compose_prompt(
role: "AgentRole",
team: "Team | None",
@@ -103,8 +117,9 @@ def compose_prompt(
Combines:
1. base.md - Universal rules (all agents)
2. roles/{role}.md - Role-specific behavior
3. teams/{team}.md - Team context (if team is set)
4. identities/{agent_slug}.md - Agent identity
3. _generated/{role}.md - Autogenerated verb-signature table from schemas
4. teams/{team}.md - Team context (if team is set)
5. identities/{agent_slug}.md - Agent identity
Args:
role: Agent's role (developer, qa, pm, documenter, board)
@@ -121,6 +136,7 @@ def compose_prompt(
for layer in (
_load_layer(prompts_path / "base.md"),
_role_layer(prompts_path, role),
_autogen_verbs_layer(prompts_path, role),
_team_layer(prompts_path, team),
_load_layer(prompts_path / "identities" / f"{agent_slug}.md"),
):
+2 -3
View File
@@ -882,9 +882,8 @@ async def submit_for_qa(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PROGRESS: Cannot submit for QA without any "
"progress updates. Add progress entries via the gateway "
"(i_have_committed auto-records) at least once during "
"execution before submitting."
"progress updates. Make at least one commit() during "
"execution — commit() auto-records a progress entry."
),
)
+2 -14
View File
@@ -12,7 +12,6 @@ from roboco.api.schemas.v2.flow import (
IAmBlockedRequest,
IAmDoneRequest,
IAmIdleRequest,
IHaveCommittedRequest,
IWillWorkOnRequest,
ResumeRequest,
SubmitForQaRequest,
@@ -21,8 +20,8 @@ from roboco.api.schemas.v2.flow import (
from roboco.services.gateway.choreographer import Choreographer
router = APIRouter(
prefix="/api/v2/flow/dev",
tags=["v2-flow-dev"],
prefix="/api/v2/flow/developer",
tags=["v2-flow-developer"],
dependencies=[require_dev],
)
@@ -53,17 +52,6 @@ async def i_will_work_on(
return envelope_to_response(env, request)
@router.post("/i_have_committed")
async def i_have_committed(
request: Request,
body: IHaveCommittedRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.i_have_committed(x_agent_id, body.message)
return envelope_to_response(env, request)
@router.post("/submit_for_qa")
async def submit_for_qa(
request: Request,
-4
View File
@@ -14,10 +14,6 @@ class IWillWorkOnRequest(BaseModel):
plan: str | None = None
class IHaveCommittedRequest(BaseModel):
message: str = Field(..., min_length=1)
class SubmitForQaRequest(BaseModel):
task_id: UUID
+5 -3
View File
@@ -299,10 +299,12 @@ class Settings(BaseSettings):
description="Enable Agent Gateway feature (Phase 0 flag)",
)
manifest_host_dir: str = Field(
default="/var/lib/roboco/manifests",
default="/app/manifests",
description=(
"Host-side directory where per-agent tool manifests are written before "
"being bind-mounted into developer containers as /app/tool-manifest.json"
"Orchestrator-side directory where per-agent tool manifests are "
"written. Must be a path that's bind-mounted from the host "
"(see docker-compose.yaml) so the docker daemon can in turn mount "
"the file into spawned agent containers as /app/tool-manifest.json."
),
)
public_base_url: str = Field(
+7 -2
View File
@@ -41,11 +41,16 @@ VALID_TRANSITIONS: dict[str, list[str]] = {
"pending": ["claimed", "cancelled"],
# Claimed - can start, unclaim, or cancel
"claimed": ["in_progress", "pending", "cancelled"],
# In progress - can block, pause, verify, submit for PM review, complete, or cancel
# QA direct assignment: QA can also pass/fail when assigned directly
# In progress - can block, pause, verify, submit for PM review, complete,
# cancel, OR drop back to pending via voluntary unclaim / reaper sweep.
# QA direct assignment: QA can also pass/fail when assigned directly.
# Pre-P2-4 the reaper used raw SQL to bypass this list; routing through
# _validate_and_set_status now means the canonical state machine sees the
# actual production transitions.
"in_progress": [
"blocked",
"paused",
"pending", # voluntary unclaim or reaper sweep
"verifying",
"awaiting_pm_review",
"awaiting_documentation", # QA pass when assigned directly
+37 -10
View File
@@ -51,16 +51,34 @@ def _build_headers() -> dict[str, str]:
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
"""POST a request to the orchestrator and return the JSON envelope."""
"""POST a request to the orchestrator and return the JSON envelope.
Mirrors flow_server._post: surfaces the orchestrator's envelope on
both 2xx and 4xx so the agent always sees ``remediate``. Only
fabricates a transport_error envelope when the body is unparseable.
"""
with httpx.Client(timeout=_TIMEOUT) as client:
response = client.post(
f"{ORCHESTRATOR_URL}{path}",
headers=_build_headers(),
json=body,
)
response.raise_for_status()
result: dict[str, Any] = response.json()
return result
try:
payload: dict[str, Any] = response.json()
except (ValueError, json.JSONDecodeError):
return {
"error": "transport_error",
"message": (
f"orchestrator returned HTTP {response.status_code}"
f" with no JSON body for {path}"
),
"remediate": (
"check that the orchestrator is up and the route exists;"
" contact the human operator if this persists"
),
"missing": [],
}
return payload
def commit(message: str, files: list[str] | None = None) -> dict[str, Any]:
@@ -168,18 +186,27 @@ def _load_manifest_do_tools() -> list[str] | None:
def _register_tools() -> list[str]:
"""Register MCP tools according to the manifest, or all tools as a failsafe.
"""Register MCP tools according to the manifest. Fails loud if absent.
Mirrors flow_server's behaviour: refuse to start if the manifest is
missing rather than silently exposing the full do-tool set (which
includes ``commit`` the role-gate would reject it server-side, but
the agent shouldn't see it on its tool palette in the first place).
Returns the list of tool names actually registered.
"""
allowed = _load_manifest_do_tools()
if allowed is None:
log.warning(
"do_server: manifest unavailable; registering all do tools",
role=AGENT_ROLE,
manifest_path = os.environ.get(
"ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"
)
names = list(_TOOLS)
else:
msg = (
f"do_server: manifest unavailable at {manifest_path};"
f" refusing to register all-tools fallback for role"
f" {AGENT_ROLE!r}. Check the orchestrator manifest mount."
)
log.error("do_server: manifest missing", role=AGENT_ROLE, path=manifest_path)
raise RuntimeError(msg)
unknown = [verb for verb in allowed if verb not in _TOOLS]
if unknown:
log.warning(
+58 -18
View File
@@ -54,21 +54,55 @@ def _build_headers() -> dict[str, str]:
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
"""POST a request to the orchestrator and return the JSON envelope."""
"""POST a request to the orchestrator and return the JSON envelope.
The orchestrator returns the standardized envelope on both success
(2xx) and rejection (4xx). The MCP-side bridge surfaces the envelope
in either case so agents see ``remediate`` / ``missing`` even on a
4xx response. Only raises if the response has no parseable body
(e.g., a 5xx with HTML error page or a network failure).
"""
with httpx.Client(timeout=_TIMEOUT) as client:
response = client.post(
f"{ORCHESTRATOR_URL}{path}",
headers=_build_headers(),
json=body,
)
response.raise_for_status()
result: dict[str, Any] = response.json()
return result
try:
payload: dict[str, Any] = response.json()
except (ValueError, json.JSONDecodeError):
# No JSON body (HTML error page, empty body, etc). Surface the
# status as a synthetic envelope so the agent gets a remediate
# hint instead of a Python traceback.
return {
"error": "transport_error",
"message": (
f"orchestrator returned HTTP {response.status_code}"
f" with no JSON body for {path}"
),
"remediate": (
"check that the orchestrator is up and the route exists;"
" contact the human operator if this persists"
),
"missing": [],
}
return payload
# Board route serves Product Owner + Head Marketing under one prefix.
# AgentRole values that map to a different URL segment go here; everything
# else passes through unchanged so route prefix == role name (developer,
# qa, documenter, cell_pm, main_pm, auditor).
_ROLE_TO_ROUTE_PREFIX: dict[str, str] = {
"product_owner": "board",
"head_marketing": "board",
}
_ROUTE_PREFIX = _ROLE_TO_ROUTE_PREFIX.get(AGENT_ROLE, AGENT_ROLE)
def _role_path(verb: str) -> str:
"""Build the role-scoped /api/v2/flow/<role>/<verb> path."""
return f"/api/v2/flow/{AGENT_ROLE}/{verb}"
"""Build the role-scoped /api/v2/flow/<route>/<verb> path."""
return f"/api/v2/flow/{_ROUTE_PREFIX}/{verb}"
# ---------- Dev verbs ----------
@@ -84,11 +118,6 @@ 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 i_have_committed(message: str) -> dict[str, Any]:
"""Record that you made a commit. Auto-creates progress entry."""
return _post(_role_path("i_have_committed"), {"message": message})
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})
@@ -235,7 +264,6 @@ _TOOLS: dict[str, Any] = {
# dev
"give_me_work": give_me_work,
"i_will_work_on": i_will_work_on,
"i_have_committed": i_have_committed,
"submit_for_qa": submit_for_qa,
"i_am_done": i_am_done,
"i_am_blocked": i_am_blocked,
@@ -294,18 +322,30 @@ def _load_manifest_flow_tools() -> list[str] | None:
def _register_tools() -> list[str]:
"""Register MCP tools according to the manifest, or all tools as a failsafe.
"""Register MCP tools according to the manifest. Fails loud if absent.
The manifest is the role-authoritative tool list. Falling back to
all-verbs registration (the previous behaviour) caused PMs to see
developer/QA verbs and call them at wrong URLs (404s) see audit
2026-05-04 D-12. We now refuse to start without the manifest.
Returns the list of verb names actually registered.
"""
allowed = _load_manifest_flow_tools()
if allowed is None:
log.warning(
"flow_server: manifest unavailable; registering all flow verbs",
role=AGENT_ROLE,
manifest_path = os.environ.get(
"ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"
)
names = list(_TOOLS)
else:
msg = (
f"flow_server: manifest unavailable at {manifest_path};"
f" refusing to register all-verbs fallback (would let"
f" {AGENT_ROLE!r} call off-role verbs at wrong URLs)."
f" Check that the orchestrator wrote the manifest to its"
f" /app/manifests/ directory and that the agent container"
f" has the bind-mount."
)
log.error("flow_server: manifest missing", role=AGENT_ROLE, path=manifest_path)
raise RuntimeError(msg)
unknown = [verb for verb in allowed if verb not in _TOOLS]
if unknown:
log.warning(
+113 -10
View File
@@ -230,10 +230,18 @@ def _build_manifest_for_agent(agent_id: str, model: str) -> Path | None:
)
)
host_dir = Path(settings.manifest_host_dir)
host_path = host_dir / f"{agent_id}.json"
write_manifest(manifest, host_path)
return host_path
# Two paths in play:
# - orchestrator-internal: where the file is written inside the
# orchestrator container (settings.manifest_host_dir). The compose
# volume mount makes this dir visible on the host.
# - host-side: what the docker daemon needs for the bind-mount into
# the spawned agent. Computed via DATA_HOST_PATH translation.
write_dir = Path(settings.manifest_host_dir)
write_path = write_dir / f"{agent_id}.json"
write_manifest(manifest, write_path)
if DATA_HOST_PATH:
return Path(f"{DATA_HOST_PATH}/manifests/{agent_id}.json")
return write_path
# =============================================================================
@@ -475,6 +483,13 @@ class AgentOrchestrator:
# agents that were WAITING_LONG at shutdown can still be resolved.
await self.restore_waiting_records()
# Self-heal: roll back orphan claims left over from a prior crash
# (audit P2-8). Tasks that show CLAIMED/IN_PROGRESS but have NO
# branch_name set indicate _finalize_claim flushed the status before
# branch creation failed in a pre-P0-7 run. Without this, the next
# claim attempt fails non-idempotent on `git checkout -b`.
await self._reconcile_orphan_claims_on_startup()
# Note: Per-agent settings are now generated at spawn time
# via _generate_agent_settings() - no shared settings needed
@@ -1451,6 +1466,15 @@ class AgentOrchestrator:
f"{hosts['claude']}:/home/agent/.claude",
]
# Claude CLI also reads ~/.claude.json (a sibling FILE, not under
# ~/.claude/). When that file isn't mounted, the CLI logs
# "config not found" and falls back to a backup inside ~/.claude/
# (audit D-48). Mount the host's claude.json if it exists so each
# agent boots from the same source of truth as the host.
claude_json_host = f"{hosts['claude'].rstrip('/')}.json"
if Path(claude_json_host).exists():
cmd.extend(["-v", f"{claude_json_host}:/home/agent/.claude.json"])
settings_host = hosts.get("settings")
if settings_host:
cmd.extend(["-v", f"{settings_host}:/home/agent/.claude/settings.json:ro"])
@@ -3568,6 +3592,48 @@ Start now: evidence(task_id="{task_id}")
except Exception as e:
logger.error("Dispatcher loop error", error=str(e))
async def _reconcile_orphan_claims_on_startup(self) -> None:
"""Roll back tasks left in CLAIMED/IN_PROGRESS without a branch.
A task in CLAIMED/IN_PROGRESS with ``branch_name IS NULL`` is an
orphan: ``_finalize_claim`` flushed the status before branch creation
failed (or before the P0-7 rollback fix landed). The next claim then
fails non-idempotent on ``git checkout -b`` because the on-disk
branch may exist while the DB state is stale.
Best-effort: if reconciliation itself fails, log and continue
startup must not be blocked by a single bad row.
"""
from roboco.db.base import get_session_factory
from roboco.services.task import TaskService
factory = get_session_factory()
try:
async with factory() as db:
svc = TaskService(db)
candidates = await svc.list_in_progress_or_claimed()
orphans = [t for t in candidates if not t.branch_name]
if not orphans:
logger.info("startup reconcile: no orphan claims")
return
for t in orphans:
try:
await svc.unclaim_for_reaper(t.id)
logger.warning(
"startup reconcile: orphan claim rolled back",
task_id=str(t.id),
had_status=str(t.status),
)
except Exception as exc:
logger.error(
"startup reconcile: rollback failed",
task_id=str(t.id),
error=str(exc),
)
await db.commit()
except Exception as exc:
logger.error("startup reconcile failed; continuing", error=str(exc))
async def _reap_stale_claims(self) -> None:
"""Release claimed/in_progress tasks whose holder hasn't heart-beat in TTL.
@@ -4293,6 +4359,22 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
owner_uuid = self._resolve_dev_owner_uuid(task)
agent_slug = self._resolve_agent_slug(owner_uuid) if owner_uuid else None
# Role/task_type mismatch guard (audit D-49). The dispatcher
# previously trusted whatever ``assigned_to`` named, so a
# documentation task accidentally assigned to a developer agent
# would silently spawn the dev. Reject the dispatch if the
# assignee's role doesn't match the task type — the PM that
# mis-assigned needs to fix it before any agent runs.
if agent_slug and not self._dev_dispatch_role_matches(task, agent_slug):
logger.warning(
"dev dispatch: role/task_type mismatch — skipping spawn",
task_id=task.get("id"),
task_type=task.get("task_type"),
assignee_slug=agent_slug,
assignee_role=get_agent_role(agent_slug),
)
return
if agent_slug and status in (
"needs_revision",
"in_progress",
@@ -4306,6 +4388,27 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
if agent_slug:
await self._spawn_pending_dev(client, task, agent_slug)
@staticmethod
def _dev_dispatch_role_matches(task: dict[str, Any], agent_slug: str) -> bool:
"""Return True if the assignee role matches the task's task_type.
Dev dispatcher only spawns developer-role agents. A doc/qa task
assigned to a dev (or vice versa) should be flagged, not silently
spawned. Returns True when the type is unknown or the assignee role
is unknown the validation runs as a guard, not a strict gate, so
an unknown classification doesn't block work that would otherwise
proceed.
"""
role = get_agent_role(agent_slug)
if role is None:
return True
task_type = task.get("task_type")
if task_type == "documentation":
return role == "documenter"
# `code` / `research` / `planning` / `administrative` / `design` all
# route through dev or PM; only the doc-task case is unambiguous.
return role == "developer"
async def _spawn_assigned_qa(self, task: dict[str, Any], assigned_to: str) -> bool:
"""If task.assigned_to is a QA slug, spawn/skip-if-running; else False.
@@ -5005,15 +5108,15 @@ to begin.
Continue development. Required gates before i_am_done() will succeed
(enforced server-side `remediate` tells you what's missing):
1. commit("<type(scope): subject, >=20 chars>")
stages tracked changes; auto-prefixes task ID. Repeat per chunk.
2. i_have_committed("<progress note>")
record at least one progress entry.
3. note(scope='decision'|'learning'|'reflect', task_id="...", text=...)
makes the git commit, auto-prefixes task ID, records progress.
Repeat per meaningful chunk.
2. note(scope='decision'|'learning'|'reflect', task_id="...", text=...)
as you make trade-offs.
When acceptance criteria are met, call
i_am_done(task_id="...", notes="<self-verification summary>"):
this chains submit_verification + push + create_pr + submit_qa in one verb.
submit_for_qa(task_id="...") to push your branch and open the PR,
then i_am_done(task_id="...", notes="<self-verification summary>")
to submit for QA review.
If you hit something you can't unblock yourself:
i_am_blocked(task_id="...",
@@ -0,0 +1,41 @@
"""Gateway choreographer package.
Audit P2-2: the single-file 2,540-line ``choreographer.py`` is being
split into per-role mixins composed onto a single ``Choreographer``
class. ``board.py`` is the first extraction (Board + Auditor verbs);
the rest still live in ``_impl.py`` and will move incrementally per
the plan in ``docs/internal/audit_2026_05_04/p2_2_decompose_plan.md``.
The public surface (``Choreographer``, ``ChoreographerDeps``,
``DelegateInputs``) is re-exported here so every caller's import path
``from roboco.services.gateway.choreographer import Choreographer``
continues to resolve.
"""
from __future__ import annotations
from roboco.services.gateway.choreographer._impl import (
Choreographer as _LegacyChoreographer,
)
from roboco.services.gateway.choreographer._impl import (
ChoreographerDeps,
DelegateInputs,
)
from roboco.services.gateway.choreographer.board import BoardMixin
from roboco.services.gateway.choreographer.doc import DocMixin
from roboco.services.gateway.choreographer.qa import QAMixin
class Choreographer(BoardMixin, DocMixin, QAMixin, _LegacyChoreographer):
"""Composed choreographer.
MRO walks left-to-right: extracted mixins resolve first, then the
legacy class supplies everything not yet split out (helpers, deps,
__init__, all remaining role verbs). Future per-role splits add
mixins to the left of _LegacyChoreographer in this declaration;
once everything has moved out, _LegacyChoreographer becomes
``BaseChoreographer`` (deps + helpers only).
"""
__all__ = ["Choreographer", "ChoreographerDeps", "DelegateInputs"]
@@ -17,7 +17,6 @@ from uuid import UUID
import structlog
from roboco.config import settings
from roboco.services.gateway.claim_guards import (
already_active_guard,
paused_tasks_guard,
@@ -147,18 +146,24 @@ class Choreographer:
failures must NEVER block the verb (the agent's response is the
contract; the audit row is observability-only).
Also stashes ``correlation_id`` from the structlog contextvars
(bound by ``CorrelationIdMiddleware`` for the inbound request)
into the audit row's ``details`` JSONB so post-mortem joins
across logs and audit trail are possible.
Stashes ``correlation_id`` from the structlog contextvars (bound
by ``CorrelationIdMiddleware`` for the inbound request) and a
per-attempt id into the audit row's ``details`` JSONB. The
attempt_id is unique per rejection event so post-mortem queries
can group "all attempts on task X within a window" without
confusing two distinct calls that share a correlation_id (audit
P2-7/D-N).
"""
if env.error is None:
return env
from uuid import uuid4 as _uuid4
details: dict[str, Any] = {
"verb": verb,
"reason": env.error,
"message": env.message,
"missing": env.missing or [],
"attempt_id": str(_uuid4()),
}
cid = structlog.contextvars.get_contextvars().get("correlation_id")
if cid is not None:
@@ -292,6 +297,32 @@ class Choreographer:
# Format: "<id> (<status>)"
return ", ".join(f"{s.id} ({s.status})" for s in non_terminal)
async def _subtasks_not_terminal_envelope(
self,
agent_id: UUID,
task_id: UUID,
*,
context_phrase: str,
) -> Envelope | None:
"""Return a tracing_gap rejection if any subtask of ``task_id`` is non-terminal.
Centralizes the closure-time "all subtasks terminal" gate that fires
in submit_up, cell_pm_complete, main_pm_complete (audit P2-3/D-15).
``context_phrase`` lets each caller name the action being blocked
(e.g., "bubbling up", "completing parent").
"""
if await self.task.all_subtasks_terminal(task_id):
return None
non_terminal = await self._non_terminal_subtask_ids(task_id)
return Envelope.tracing_gap(
missing=["subtasks not all terminal"],
remediate=(
f"all subtasks must be in completed/cancelled before"
f" {context_phrase}. Non-terminal subtasks: {non_terminal}"
),
context_briefing=await self._briefing_for(agent_id, task_id),
)
async def _i_will_work_on_pending(
self,
agent_id: UUID,
@@ -308,7 +339,21 @@ class Choreographer:
if guard := await self._run_claim_guards(agent_id=agent_id, task=t):
return self._with_briefing(guard, briefing), t
# claim() transitions pending → claimed; idempotent for same assignee.
# Branch creation runs inside _finalize_claim and rolls back on
# failure (audit P0-7 / S-01); we surface the failure as an envelope
# so the agent gets remediate instead of a 500.
try:
t = await self.task.claim(task_id, agent_id)
except Exception as exc:
return Envelope.invalid_state(
message=f"claim failed during finalization: {exc}",
remediate=(
"branch or workspace setup failed; the claim was rolled"
" back. Check workspace + token, then retry"
" i_will_work_on(task_id, plan)."
),
context_briefing=briefing,
), None
if t is None:
return Envelope.invalid_state(
message="claim failed",
@@ -327,6 +372,64 @@ class Choreographer:
if plan:
t = await self.task.set_plan(task_id, plan)
t = await self.task.start(task_id, agent_id)
if t is None:
return self._start_failed_envelope(task_id, briefing), t
return None, t
@staticmethod
def _start_failed_envelope(task_id: UUID, briefing: dict[str, Any]) -> Envelope:
"""Rejection envelope when ``task.start()`` returns None.
``start()`` returns None on invalid status, ownership mismatch, or
missing plan. Surface the failure rather than fall through to an
OK envelope that dereferences ``None.status``.
"""
return Envelope.invalid_state(
message=f"start failed for task {task_id}",
remediate=(
"task not in a startable state"
" (claimed/paused/needs_revision) or no plan recorded"
),
context_briefing=briefing,
)
async def _i_will_work_on_needs_revision(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
briefing: dict[str, Any],
) -> tuple[Envelope | None, Any]:
"""needs_revision branch for i_will_work_on. Returns (rejection|None, task)."""
if t.assigned_to != agent_id:
t = await self.task.claim(task_id, agent_id)
if t is None:
return Envelope.invalid_state(
message="claim failed",
remediate="task may already be claimed by another agent",
context_briefing=briefing,
), None
t = await self.task.start(task_id, agent_id)
if t is None:
return self._start_failed_envelope(task_id, briefing), None
return None, t
async def _i_will_work_on_claimed(
self,
agent_id: UUID,
task_id: UUID,
t: Any,
briefing: dict[str, Any],
) -> tuple[Envelope | None, Any]:
"""claimed branch for i_will_work_on. Returns (rejection|None, task)."""
guard = await self._run_claim_guards(
agent_id=agent_id, task=t, skip_sequence=True
)
if guard:
return self._with_briefing(guard, briefing), None
t = await self.task.start(task_id, agent_id)
if t is None:
return self._start_failed_envelope(task_id, briefing), None
return None, t
async def i_will_work_on(
@@ -344,37 +447,19 @@ class Choreographer:
status = str(t.status)
briefing = await self._briefing_for(agent_id, task_id)
rejection: Envelope | None = None
if status == "needs_revision":
# Resumption after QA rejection: agent already owned the task,
# role-typed claim already passed at original claim time.
if t.assigned_to != agent_id:
t = await self.task.claim(task_id, agent_id)
t = await self.task.start(task_id, agent_id)
rejection, t = await self._i_will_work_on_needs_revision(
agent_id, task_id, t, briefing
)
elif status == "pending":
rejection, t = await self._i_will_work_on_pending(
agent_id, task_id, t, plan, briefing
)
if rejection is not None:
return await self._emit_rejection(
rejection,
agent_id=agent_id,
task_id=task_id,
verb="i_will_work_on",
)
elif status == "claimed" and t.assigned_to == agent_id:
# Resumption: skip sibling-sequence (already passed at claim).
# Still enforce already_active/paused so concurrent claims fail.
guard = await self._run_claim_guards(
agent_id=agent_id, task=t, skip_sequence=True
rejection, t = await self._i_will_work_on_claimed(
agent_id, task_id, t, briefing
)
if guard:
return await self._emit_rejection(
self._with_briefing(guard, briefing),
agent_id=agent_id,
task_id=task_id,
verb="i_will_work_on",
)
t = await self.task.start(task_id, agent_id)
elif status == "in_progress" and t.assigned_to == agent_id:
# Idempotent re-entry: respawned dev re-calling i_will_work_on
# on a task they already own in_progress. Skip start() (would
@@ -383,12 +468,15 @@ class Choreographer:
# reaper activity.
pass
else:
return await self._emit_rejection(
Envelope.invalid_state(
rejection = Envelope.invalid_state(
message=f"task {task_id} is in {status}; cannot start work",
remediate="call give_me_work() to find an actionable task",
context_briefing=briefing,
),
)
if rejection is not None:
return await self._emit_rejection(
rejection,
agent_id=agent_id,
task_id=task_id,
verb="i_will_work_on",
@@ -399,8 +487,8 @@ class Choreographer:
status=str(t.status),
task_id=str(task_id),
next=(
"edit + commit; call i_have_committed when ready,"
" or i_am_done when finished"
"edit + commit(message) for each meaningful change,"
" then submit_for_qa(task_id) and i_am_done(task_id)"
),
context_briefing=briefing,
)
@@ -411,46 +499,6 @@ class Choreographer:
env.context_briefing = briefing
return env
async def i_have_committed(self, agent_id: UUID, message: str) -> Envelope:
"""Record that the dev made a commit; auto-creates progress entry."""
t = await self.task.get_active_task_for_agent(agent_id)
if t is None:
return await self._emit_rejection(
Envelope.invalid_state(
message="no active task for this agent",
remediate=(
"call give_me_work() then i_will_work_on(task_id, plan)"
),
context_briefing=await self._briefing_for(agent_id, None),
),
agent_id=agent_id,
task_id=None,
verb="i_have_committed",
)
if not t.plan:
no_plan_remediate = (
f"plan must be set first;"
f" call i_will_work_on(task_id='{t.id}', plan='...')"
)
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["plan"],
remediate=no_plan_remediate,
context_briefing=await self._briefing_for(agent_id, t.id),
),
agent_id=agent_id,
task_id=t.id,
verb="i_have_committed",
)
await self.task.add_progress(t.id, agent_id, message)
await self._touch(t.id)
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next="continue working, or i_am_done when finished",
context_briefing=await self._briefing_for(agent_id, t.id),
)
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.
@@ -526,17 +574,21 @@ class Choreographer:
)
async def i_am_done(self, agent_id: UUID, task_id: UUID, notes: str) -> Envelope:
"""Submit work for QA — strict path.
"""Submit work for QA.
Pre-gateway, the route layer enforced four field-level gates
(NOT_SELF_VERIFIED, NO_COMMITS, NO_PR, NO_PROGRESS) before
transitioning verifying awaiting_qa. The strict gateway path
re-enforces those exactly: dev MUST have already committed,
pushed, opened a PR, reported progress, and self-verified before
i_am_done can submit.
Preconditions enforced by gates:
- 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
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.
For the smart-catch-up convenience that auto-runs the chain
on the dev's behalf, see ``i_am_done_with_catchup``.
The previous strict path required a separate ``submit_for_verification``
verb that wasn't on any manifest, making i_am_done unreachable
(audit D-08). Removed that requirement; the act of calling i_am_done
IS the self-verification.
"""
t = await self.task.get(task_id)
if t is None:
@@ -564,13 +616,20 @@ class Choreographer:
rejection, agent_id=agent_id, task_id=task_id, verb="i_am_done"
)
# 2. Field-level gates (Gate Set E) — strict.
# 2. Field-level gates (Gate Set E) — commits + PR (self_verified
# auto-set in step 3, so it's not a precondition the dev must satisfy).
if rejection := await self._check_submit_qa_field_gates(agent_id, task_id, t):
return await self._emit_rejection(
rejection, agent_id=agent_id, task_id=task_id, verb="i_am_done"
)
# 3. Submit (no catch-up).
# 3. Auto-run in_progress → verifying (sets self_verified) if needed.
if str(t.status) == "in_progress":
verified = await self.task.submit_verification(agent_id, task_id, notes)
if verified is not None:
t = verified
# 4. Submit verifying → awaiting_qa.
submitted = await self.task.submit_qa(agent_id, task_id, notes)
if submitted is not None:
t = submitted
@@ -578,50 +637,6 @@ class Choreographer:
await self._touch(task_id)
return await self._build_i_am_done_ok(agent_id, task_id, t)
async def i_am_done_with_catchup(
self, agent_id: UUID, task_id: UUID, notes: str
) -> Envelope:
"""Submit work for QA — opt-in smart catch-up.
Same tracing-gate preconditions as ``i_am_done``, but auto-runs
the verify / push / PR / submit_qa chain on the dev's behalf
instead of refusing on missing fields. Use this when the dev
explicitly wants the gateway to drive the closure path.
Pre-gateway behavior: dev had to call each step manually. The
catch-up convenience exists for backward compat with workflows
that rely on the implicit chain.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="i_am_done_with_catchup",
)
if t.assigned_to != agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via i_will_work_on(task_id) first",
context_briefing=await self._briefing_for(agent_id, task_id),
),
agent_id=agent_id,
task_id=task_id,
verb="i_am_done_with_catchup",
)
if rejection := await self._check_tracing_gates(agent_id, task_id, t):
return await self._emit_rejection(
rejection,
agent_id=agent_id,
task_id=task_id,
verb="i_am_done_with_catchup",
)
t = await self._run_catch_up(agent_id, task_id, t, notes)
await self._notify_qa(agent_id, task_id, t)
return await self._build_i_am_done_ok(agent_id, task_id, t)
async def _check_tracing_gates(
self, agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None:
@@ -651,13 +666,10 @@ class Choreographer:
"""
missing: list[str] = []
hints: list[str] = []
if not t.self_verified:
missing.append("NOT_SELF_VERIFIED")
hints.append(
"call commit(message=...) to add a self-verified commit first;"
" self_verified is set automatically when you commit on this"
" task's branch"
)
# NOTE: self_verified is no longer a precondition — i_am_done auto-runs
# the in_progress → verifying transition which sets it. The previous
# NOT_SELF_VERIFIED gate required a separate submit_for_verification
# verb that wasn't on any manifest (audit D-08).
if not t.commits:
missing.append("NO_COMMITS")
hints.append(
@@ -667,8 +679,8 @@ class Choreographer:
if t.pr_number is None:
missing.append("NO_PR")
hints.append(
"no PR open — push your branch and open a PR before"
" i_am_done (or call i_am_done_with_catchup to do it auto)"
"no PR open — call submit_for_qa(task_id) to push your"
" branch and open the PR, then retry i_am_done"
)
if not missing:
return None
@@ -727,24 +739,6 @@ class Choreographer:
context_briefing=await self._briefing_for(agent_id, task_id),
)
async def _run_catch_up(
self, agent_id: UUID, task_id: UUID, t: Any, notes: str
) -> Any:
"""Run verification, push, PR creation, and submit_qa as needed."""
if not t.self_verified:
t = await self.task.submit_verification(agent_id, task_id, notes)
has_unpushed = await self.work_session.has_unpushed_commits(t.work_session_id)
if has_unpushed:
await self.git.push_branch(t.branch_name)
if t.pr_number is None:
parent = parent_branch_for(t.branch_name)
await self.git.create_pr(t.branch_name, parent=parent, is_root_pr=False)
t = await self.task.get(task_id) # refresh after PR creation
return await self.task.submit_qa(agent_id, task_id, notes)
async def _notify_qa(self, agent_id: UUID, task_id: UUID, t: Any) -> None:
"""Reassign + A2A-notify the QA agent for this task's team.
@@ -767,10 +761,18 @@ class Choreographer:
def _resolve_skill(self, target_agent: Any, preference: list[str]) -> str:
"""Pick first skill in preference list that target_agent has.
Falls back to the first entry in preference when no match is found.
Reads from either ``skills`` (gateway view, list of dicts with
``id`` keys) or ``capabilities`` (SQLAlchemy AgentTable, list of
strings). The DB-side AgentTable has no ``skills`` attribute,
so a naive ``target_agent.skills`` raises AttributeError on
production agents (audit D-06). Falls back to the first entry
in ``preference`` when no match is found.
"""
skills_attr = getattr(target_agent, "skills", None)
capabilities_attr = getattr(target_agent, "capabilities", None)
raw = skills_attr if skills_attr is not None else capabilities_attr
have: set[str] = set()
for s in target_agent.skills or []:
for s in raw or []:
if isinstance(s, dict):
sid = s.get("id")
if sid:
@@ -908,7 +910,7 @@ class Choreographer:
return Envelope.ok(
status=str(after.status),
task_id=str(task_id),
next="resumed; continue working — i_have_committed when ready",
next="resumed; continue working — call commit() when ready",
context_briefing=briefing,
)
@@ -1014,419 +1016,11 @@ class Choreographer:
paused_ids.append(str(t.id))
return paused_ids
# --- Phase 2 (QA) verbs ---
async def claim_review(self, qa_agent_id: UUID, task_id: UUID) -> Envelope:
"""QA agent claims task in awaiting_qa for review.
The response includes evidence (pr_url, pr_number, commits, files_changed,
journal_highlights, acceptance_criteria_status) INLINE so the QA agent
cannot miss the PR data. Marks `qa_evidence_inspected=true` automatically.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id,
task_id=task_id,
verb="claim_review",
)
if str(t.status) != "awaiting_qa":
return await self._emit_rejection(
Envelope.invalid_state(
message=(
f"task {task_id} is in {t.status}, "
"expected awaiting_qa for review"
),
remediate="call give_me_work() to find an actionable QA task",
context_briefing=await self._briefing_for(qa_agent_id, task_id),
),
agent_id=qa_agent_id,
task_id=task_id,
verb="claim_review",
)
# Gate Set A: ALREADY_ACTIVE / PAUSED_TASKS_EXIST guard QA from
# juggling reviews while their previous in_progress task is open.
# role_typed/PM-code skipped: QA verb only ever fires for QA role.
# sequence skipped: QA reviews are siblings on a different axis.
guard = await self._run_claim_guards(
agent_id=qa_agent_id,
task=t,
skip_role_typed=True,
skip_pm_code=True,
skip_sequence=True,
)
if guard:
return await self._emit_rejection(
self._with_briefing(
guard, await self._briefing_for(qa_agent_id, task_id)
),
agent_id=qa_agent_id,
task_id=task_id,
verb="claim_review",
)
t = await self.task.qa_claim(qa_agent_id, task_id)
# Auto-mark evidence as inspected — we surface it inline in this response
await self.task.mark_evidence_inspected(task_id)
files_changed: list[str] = []
if t.work_session_id:
files_changed = await self.work_session.files_changed(t.work_session_id)
diff_summary = ""
if t.branch_name:
diff_summary = await self.git.diff(branch_name=t.branch_name)
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
ev = build_evidence_for_task(
t,
journal_highlights=journal_highlights,
files_changed=files_changed,
pr_diff_summary=diff_summary,
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"review the diff. Then call pass(notes) to accept or "
"fail(issues) to request changes."
),
evidence=ev.as_dict(),
context_briefing=await self._briefing_for(qa_agent_id, task_id),
)
async def pass_review(
self, qa_agent_id: UUID, task_id: UUID, notes: str
) -> Envelope:
"""QA passes the task; transitions awaiting_qa → awaiting_documentation.
Gated on tracing requirements: qa_notes >= settings.qa_notes_min_chars,
journal:learning entry exists for this task, and qa_evidence_inspected
is True (auto-set by claim_review).
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
if t.assigned_to != qa_agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_review(task_id) first",
context_briefing=await self._briefing_for(qa_agent_id, task_id),
),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
has_learning = await self.journal.has_learning_for_task(qa_agent_id, task_id)
missing = self._check_qa_pass_gates(
notes=notes,
has_learning=has_learning,
evidence_inspected=t.qa_evidence_inspected,
)
if missing:
return await self._emit_rejection(
self._qa_tracing_gap(
missing, task_id, await self._briefing_for(qa_agent_id, task_id)
),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
t = await self.task.qa_pass(qa_agent_id, task_id, notes)
# qa_pass clears assigned_to to None; reassign to the team's
# documenter so the orchestrator spawns the right agent next.
doc_agent = await self.task.documenter_for_team(t.team)
if doc_agent is not None:
await self.task.reassign(task_id, doc_agent.id)
await self.a2a.send(
from_agent=qa_agent_id,
to_agent=doc_agent.id,
skill="documentation",
task_id=task_id,
body=f"QA passed task {t.id}. PR: {t.pr_url}. Please document.",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle until next QA work arrives",
context_briefing=await self._briefing_for(qa_agent_id, task_id),
)
def _check_qa_pass_gates(
self, *, notes: str, has_learning: bool, evidence_inspected: bool
) -> list[str]:
"""Return list of missing gate keys; empty list if all pass."""
missing: list[str] = []
if not notes or len(notes) < settings.qa_notes_min_chars:
missing.append("qa_notes>=min")
if not has_learning:
missing.append("journal:learning")
if not evidence_inspected:
missing.append("qa_evidence_inspected")
return missing
def _qa_tracing_gap(
self, missing: list[str], task_id: UUID, briefing: dict[str, Any]
) -> Envelope:
"""Build a tracing_gap envelope with role-appropriate hints."""
from roboco.services.gateway.remediation import (
hint_for_evidence_not_inspected,
hint_for_missing_journal_learning,
hint_for_missing_qa_notes,
)
hint_map = {
"qa_notes>=min": hint_for_missing_qa_notes(),
"journal:learning": hint_for_missing_journal_learning(),
"qa_evidence_inspected": hint_for_evidence_not_inspected(
task_id=str(task_id)
),
}
hints = [hint_map[m] for m in missing if m in hint_map]
return Envelope.tracing_gap(
missing=missing,
remediate=" ; ".join(hints),
context_briefing=briefing,
)
async def fail_review(
self, qa_agent_id: UUID, task_id: UUID, issues: list[str]
) -> Envelope:
"""QA fails the task with concrete issues; transitions to needs_revision."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
if t.assigned_to != qa_agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_review(task_id) first",
context_briefing=await self._briefing_for(qa_agent_id, task_id),
),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
if not issues:
return await self._emit_rejection(
Envelope.invalid_state(
message="fail_review requires at least one issue",
remediate="pass issues=['<concrete actionable issue>', ...]",
context_briefing=await self._briefing_for(qa_agent_id, task_id),
),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
has_learning = await self.journal.has_learning_for_task(qa_agent_id, task_id)
notes = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues)
missing = self._check_qa_pass_gates(
notes=notes,
has_learning=has_learning,
evidence_inspected=t.qa_evidence_inspected,
)
if missing:
return await self._emit_rejection(
self._qa_tracing_gap(
missing, task_id, await self._briefing_for(qa_agent_id, task_id)
),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
t = await self.task.qa_fail(qa_agent_id, task_id, notes, issues)
# A2A back to original developer (now reassigned)
if t.assigned_to is not None:
await self.a2a.send(
from_agent=qa_agent_id,
to_agent=t.assigned_to,
skill="code_review",
task_id=task_id,
body=f"QA needs changes. Issues:\n{notes}",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle — dev will revise and re-submit",
context_briefing=await self._briefing_for(qa_agent_id, task_id),
)
# --- Phase 2 (QA) verbs moved to ``qa.py`` (audit P2-2). ---
# --- Phase 3 (documenter + PM) verbs ---
async def claim_doc_task(self, doc_agent_id: UUID, task_id: UUID) -> Envelope:
"""Documenter claims task in awaiting_documentation; returns evidence inline."""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=doc_agent_id,
task_id=task_id,
verb="claim_doc_task",
)
if str(t.status) != "awaiting_documentation":
return await self._emit_rejection(
Envelope.invalid_state(
message=(
f"task {task_id} is in {t.status}, "
"expected awaiting_documentation"
),
remediate="call give_me_work() to find an actionable doc task",
context_briefing=await self._briefing_for(doc_agent_id, task_id),
),
agent_id=doc_agent_id,
task_id=task_id,
verb="claim_doc_task",
)
# Gate Set A: ALREADY_ACTIVE / PAUSED_TASKS_EXIST. The doc verb only
# ever fires for documenter role; PM-code and role-typed claim guards
# are skipped. Sequence guard is also irrelevant here.
guard = await self._run_claim_guards(
agent_id=doc_agent_id,
task=t,
skip_role_typed=True,
skip_pm_code=True,
skip_sequence=True,
)
if guard:
return await self._emit_rejection(
self._with_briefing(
guard, await self._briefing_for(doc_agent_id, task_id)
),
agent_id=doc_agent_id,
task_id=task_id,
verb="claim_doc_task",
)
t = await self.task.doc_claim(doc_agent_id, task_id)
files_changed: list[str] = []
if t.work_session_id:
files_changed = await self.work_session.files_changed(t.work_session_id)
diff = ""
if t.branch_name:
diff = await self.git.diff(branch_name=t.branch_name)
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
task_id
)
ev = build_evidence_for_task(
t,
journal_highlights=journal_highlights,
files_changed=files_changed,
pr_diff_summary=diff,
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"write docs in your workspace, commit them, then call "
"i_documented(task_id, notes, files)"
),
evidence=ev.as_dict(),
context_briefing=await self._briefing_for(doc_agent_id, task_id),
)
async def i_documented(
self,
doc_agent_id: UUID,
task_id: UUID,
notes: str,
files: list[str],
) -> Envelope:
"""Documenter signals docs complete.
Transitions awaiting_documentation awaiting_pm_review.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
if t.assigned_to != doc_agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_doc_task(task_id) first",
context_briefing=await self._briefing_for(doc_agent_id, task_id),
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
if not notes or len(notes) < settings.docs_notes_min_chars:
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["docs_notes>=20"],
remediate=(
"i_documented requires notes>=20 chars summarizing what you "
"documented and where (file paths)."
" Include each file in `files=...`."
),
context_briefing=await self._briefing_for(doc_agent_id, task_id),
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
if not files:
return await self._emit_rejection(
Envelope.tracing_gap(
missing=["files"],
remediate=(
"i_documented requires files=['<path>', ...]"
" listing the doc files written."
),
context_briefing=await self._briefing_for(doc_agent_id, task_id),
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
t = await self.task.docs_complete(
doc_agent_id, task_id, notes=notes, files=files
)
# docs_complete may have promoted the task to awaiting_pm_review and
# already routed it to the PM up the parent chain
# (_maybe_advance_to_pm_review). Explicitly reassign anyway to the
# cell PM for this team — guarantees a respawn target even when the
# parent-chain heuristic returns None or picks the wrong PM.
pm_agent = await self.task.cell_pm_for_team(t.team)
if pm_agent is not None:
await self.task.reassign(task_id, pm_agent.id)
await self.a2a.send(
from_agent=doc_agent_id,
to_agent=pm_agent.id,
skill="task_management",
task_id=task_id,
body=f"Docs complete for {t.id}. Ready for PM review + merge.",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle until PM completes",
context_briefing=await self._briefing_for(doc_agent_id, task_id),
)
# claim_doc_task + i_documented moved to ``doc.py`` (audit P2-2).
async def _i_will_plan_preflight(
self, pm_agent_id: UUID, task_id: UUID, t: Any, plan: str
@@ -1932,16 +1526,10 @@ class Choreographer:
remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if not await self.task.all_subtasks_terminal(task_id):
non_terminal = await self._non_terminal_subtask_ids(task_id)
return Envelope.tracing_gap(
missing=["subtasks not all terminal"],
remediate=(
"all subtasks must be in completed/cancelled before"
" bubbling up. Non-terminal subtasks: " + non_terminal
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if env := await self._subtasks_not_terminal_envelope(
pm_agent_id, task_id, context_phrase="bubbling up"
):
return env
if not t.branch_name:
return Envelope.invalid_state(
message="task has no branch; cannot open cell-level PR",
@@ -2148,17 +1736,10 @@ class Choreographer:
remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
all_terminal = await self.task.all_subtasks_terminal(task_id)
if not all_terminal:
non_terminal = await self._non_terminal_subtask_ids(task_id)
return Envelope.tracing_gap(
missing=["subtasks not all terminal"],
remediate=(
"all subtasks must be in completed/cancelled before"
" completing parent. Non-terminal subtasks: " + non_terminal
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
)
if env := await self._subtasks_not_terminal_envelope(
pm_agent_id, task_id, context_phrase="completing parent"
):
return env
if t.pr_number is None:
return Envelope.invalid_state(
message="task has no PR; cannot merge",
@@ -2290,19 +1871,10 @@ class Choreographer:
main_pm_agent_id, root_task_id
),
)
all_terminal = await self.task.all_subtasks_terminal(root_task_id)
if not all_terminal:
non_terminal = await self._non_terminal_subtask_ids(root_task_id)
return Envelope.tracing_gap(
missing=["subtasks not all terminal"],
remediate=(
"all subtasks must be in completed/cancelled state. "
"Non-terminal subtasks: " + non_terminal
),
context_briefing=await self._briefing_for(
main_pm_agent_id, root_task_id
),
)
if env := await self._subtasks_not_terminal_envelope(
main_pm_agent_id, root_task_id, context_phrase="escalating to CEO"
):
return env
return None
async def main_pm_complete(
@@ -2333,7 +1905,12 @@ class Choreographer:
if needs_pr:
await self.git.create_pr(t.branch_name, parent="master", is_root_pr=True)
t = await self.task.escalate_to_ceo(main_pm_agent_id, root_task_id, notes)
# Use kwargs — service signature is (task_id, agent_role="cell_pm",
# notes=None). Positional was passing agent_id as task_id and the
# actual task_id as agent_role (audit D-07).
t = await self.task.escalate_to_ceo(
task_id=root_task_id, agent_role="main_pm", notes=notes
)
# CEO acts via the UI, not as an agent the orchestrator spawns. Clear
# ``assigned_to`` so no agent gets respawned to chase this task while
# it sits in awaiting_ceo_approval.
@@ -2497,44 +2074,7 @@ class Choreographer:
context_briefing=await self._briefing_for(agent_id, task_id),
)
async def board_triage(self, board_agent_id: UUID) -> Envelope:
"""Phase 4: Board triage — next strategic root task awaiting PM review."""
strategic = await self.task.list_strategic_for_board()
if strategic:
t = strategic[0]
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next=(
f"review and call escalate_to_ceo(task_id='{t.id}', reason=...)"
" or i_am_idle"
),
context_briefing=await self._briefing_for(board_agent_id, t.id),
)
return Envelope.ok(
status="idle",
task_id=None,
next="no strategic-review work — i_am_idle",
context_briefing=await self._briefing_for(board_agent_id, None),
)
async def auditor_triage(self, auditor_agent_id: UUID) -> Envelope:
"""Phase 4: Auditor triage — surfaces anomalies (long-running blocked, etc.)."""
anomalies = await self.task.list_long_running_blocked()
if anomalies:
t = anomalies[0]
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next=(
"log a reflect-note observing the anomaly via "
f"note(scope='reflect', task_id='{t.id}', text='...')"
),
context_briefing=await self._briefing_for(auditor_agent_id, t.id),
)
return Envelope.ok(
status="idle",
task_id=None,
next="no anomalies — i_am_idle",
context_briefing=await self._briefing_for(auditor_agent_id, None),
)
# board_triage + auditor_triage moved to ``board.py`` as the first
# per-role mixin extraction (audit P2-2). The Choreographer class is
# composed in ``__init__.py`` from BoardMixin + the rest of this
# _impl. Methods now resolve via Python's MRO.
@@ -0,0 +1,63 @@
"""Board + Auditor verbs (P2-2 first per-role split).
Mixin extracted from ``_impl.py`` to prove the per-role pattern. Relies
on ``self.task`` and ``self._briefing_for`` from the base class via
Python's MRO. ``board_triage`` and ``auditor_triage`` are read-only
verbs that don't share helper code with any other role, making this
the safest first extraction.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from roboco.services.gateway.envelope import Envelope
if TYPE_CHECKING:
from uuid import UUID
class BoardMixin:
"""Board (Product Owner + Head Marketing) + Auditor verbs."""
async def board_triage(self, board_agent_id: UUID) -> Envelope:
"""Phase 4: Board triage — next strategic root task awaiting PM review."""
strategic = await self.task.list_strategic_for_board() # type: ignore[attr-defined]
if strategic:
t = strategic[0]
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next=(
f"review and call escalate_to_ceo(task_id='{t.id}', reason=...)"
" or i_am_idle"
),
context_briefing=await self._briefing_for(board_agent_id, t.id), # type: ignore[attr-defined]
)
return Envelope.ok(
status="idle",
task_id=None,
next="no strategic-review work — i_am_idle",
context_briefing=await self._briefing_for(board_agent_id, None), # type: ignore[attr-defined]
)
async def auditor_triage(self, auditor_agent_id: UUID) -> Envelope:
"""Phase 4: Auditor triage — surfaces anomalies (long-running blocked, etc.)."""
anomalies = await self.task.list_long_running_blocked() # type: ignore[attr-defined]
if anomalies:
t = anomalies[0]
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next=(
"log a reflect-note observing the anomaly via "
f"note(scope='reflect', task_id='{t.id}', text='...')"
),
context_briefing=await self._briefing_for(auditor_agent_id, t.id), # type: ignore[attr-defined]
)
return Envelope.ok(
status="idle",
task_id=None,
next="no anomalies — i_am_idle",
context_briefing=await self._briefing_for(auditor_agent_id, None), # type: ignore[attr-defined]
)
@@ -0,0 +1,173 @@
"""Documenter verbs (audit P2-2 second per-role split).
Mixin for ``claim_doc_task`` and ``i_documented``. Relies on the base
class for: ``self.task``, ``self.git``, ``self.work_session``,
``self.a2a``, ``self.evidence_repo``, ``self._briefing_for``,
``self._emit_rejection``, ``self._run_claim_guards``,
``self._with_briefing``. ``settings`` and ``build_evidence_for_task``
are module-level imports here.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from roboco.config import settings
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task
if TYPE_CHECKING:
from uuid import UUID
class DocMixin:
"""Documenter-role verbs."""
async def claim_doc_task(self, doc_agent_id: UUID, task_id: UUID) -> Envelope:
"""Documenter claims task in awaiting_documentation; returns evidence inline."""
t = await self.task.get(task_id) # type: ignore[attr-defined]
if t is None:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=doc_agent_id,
task_id=task_id,
verb="claim_doc_task",
)
if str(t.status) != "awaiting_documentation":
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.invalid_state(
message=(
f"task {task_id} is in {t.status}, "
"expected awaiting_documentation"
),
remediate="call give_me_work() to find an actionable doc task",
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=doc_agent_id,
task_id=task_id,
verb="claim_doc_task",
)
guard = await self._run_claim_guards( # type: ignore[attr-defined]
agent_id=doc_agent_id,
task=t,
skip_role_typed=True,
skip_pm_code=True,
skip_sequence=True,
)
if guard:
return await self._emit_rejection( # type: ignore[attr-defined]
self._with_briefing( # type: ignore[attr-defined]
guard,
await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=doc_agent_id,
task_id=task_id,
verb="claim_doc_task",
)
t = await self.task.doc_claim(doc_agent_id, task_id) # type: ignore[attr-defined]
files_changed: list[str] = []
if t.work_session_id:
files_changed = await self.work_session.files_changed(t.work_session_id) # type: ignore[attr-defined]
diff = ""
if t.branch_name:
diff = await self.git.diff(branch_name=t.branch_name) # type: ignore[attr-defined]
journal_highlights = (
await self.evidence_repo.journal_highlights_for_task(task_id) # type: ignore[attr-defined]
)
ev = build_evidence_for_task(
t,
journal_highlights=journal_highlights,
files_changed=files_changed,
pr_diff_summary=diff,
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"write docs in your workspace, commit them, then call "
"i_documented(task_id, notes, files)"
),
evidence=ev.as_dict(),
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
)
async def i_documented(
self,
doc_agent_id: UUID,
task_id: UUID,
notes: str,
files: list[str],
) -> Envelope:
"""Documenter signals docs complete.
Transitions awaiting_documentation awaiting_pm_review.
"""
t = await self.task.get(task_id) # type: ignore[attr-defined]
if t is None:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
if t.assigned_to != doc_agent_id:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_doc_task(task_id) first",
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
if not notes or len(notes) < settings.docs_notes_min_chars:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.tracing_gap(
missing=["docs_notes>=20"],
remediate=(
"i_documented requires notes>=20 chars summarizing what you "
"documented and where (file paths)."
" Include each file in `files=...`."
),
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
if not files:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.tracing_gap(
missing=["files"],
remediate=(
"i_documented requires files=['<path>', ...]"
" listing the doc files written."
),
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=doc_agent_id,
task_id=task_id,
verb="i_documented",
)
t = await self.task.docs_complete( # type: ignore[attr-defined]
doc_agent_id, task_id, notes=notes, files=files
)
pm_agent = await self.task.cell_pm_for_team(t.team) # type: ignore[attr-defined]
if pm_agent is not None:
await self.task.reassign(task_id, pm_agent.id) # type: ignore[attr-defined]
await self.a2a.send( # type: ignore[attr-defined]
from_agent=doc_agent_id,
to_agent=pm_agent.id,
skill="task_management",
task_id=task_id,
body=f"Docs complete for {t.id}. Ready for PM review + merge.",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle until PM completes",
context_briefing=await self._briefing_for(doc_agent_id, task_id), # type: ignore[attr-defined]
)
+273
View File
@@ -0,0 +1,273 @@
"""QA verbs (audit P2-2 third per-role split).
Mixin for ``claim_review``, ``pass_review``, ``fail_review`` and the
two QA-specific helpers ``_check_qa_pass_gates`` / ``_qa_tracing_gap``.
Helpers stay together with the verbs that use them they're not used
by any other role.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from roboco.config import settings
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task
if TYPE_CHECKING:
from uuid import UUID
class QAMixin:
"""QA-role verbs."""
async def claim_review(self, qa_agent_id: UUID, task_id: UUID) -> Envelope:
"""QA agent claims task in awaiting_qa for review.
The response includes evidence (pr_url, pr_number, commits, files_changed,
journal_highlights, acceptance_criteria_status) INLINE so the QA agent
cannot miss the PR data. Marks `qa_evidence_inspected=true` automatically.
"""
t = await self.task.get(task_id) # type: ignore[attr-defined]
if t is None:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id,
task_id=task_id,
verb="claim_review",
)
if str(t.status) != "awaiting_qa":
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.invalid_state(
message=(
f"task {task_id} is in {t.status}, "
"expected awaiting_qa for review"
),
remediate="call give_me_work() to find an actionable QA task",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="claim_review",
)
guard = await self._run_claim_guards( # type: ignore[attr-defined]
agent_id=qa_agent_id,
task=t,
skip_role_typed=True,
skip_pm_code=True,
skip_sequence=True,
)
if guard:
return await self._emit_rejection( # type: ignore[attr-defined]
self._with_briefing( # type: ignore[attr-defined]
guard,
await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="claim_review",
)
t = await self.task.qa_claim(qa_agent_id, task_id) # type: ignore[attr-defined]
await self.task.mark_evidence_inspected(task_id) # type: ignore[attr-defined]
files_changed: list[str] = []
if t.work_session_id:
files_changed = await self.work_session.files_changed(t.work_session_id) # type: ignore[attr-defined]
diff_summary = ""
if t.branch_name:
diff_summary = await self.git.diff(branch_name=t.branch_name) # type: ignore[attr-defined]
journal_highlights = (
await self.evidence_repo.journal_highlights_for_task(task_id) # type: ignore[attr-defined]
)
ev = build_evidence_for_task(
t,
journal_highlights=journal_highlights,
files_changed=files_changed,
pr_diff_summary=diff_summary,
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"review the diff. Then call pass(notes) to accept or "
"fail(issues) to request changes."
),
evidence=ev.as_dict(),
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
)
async def pass_review(
self, qa_agent_id: UUID, task_id: UUID, notes: str
) -> Envelope:
"""QA passes the task; transitions awaiting_qa → awaiting_documentation."""
t = await self.task.get(task_id) # type: ignore[attr-defined]
if t is None:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
if t.assigned_to != qa_agent_id:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_review(task_id) first",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
has_learning = await self.journal.has_learning_for_task( # type: ignore[attr-defined]
qa_agent_id, task_id
)
missing = self._check_qa_pass_gates(
notes=notes,
has_learning=has_learning,
evidence_inspected=t.qa_evidence_inspected,
)
if missing:
return await self._emit_rejection( # type: ignore[attr-defined]
self._qa_tracing_gap(
missing,
task_id,
await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="pass_review",
)
t = await self.task.qa_pass(qa_agent_id, task_id, notes) # type: ignore[attr-defined]
doc_agent = await self.task.documenter_for_team(t.team) # type: ignore[attr-defined]
if doc_agent is not None:
await self.task.reassign(task_id, doc_agent.id) # type: ignore[attr-defined]
await self.a2a.send( # type: ignore[attr-defined]
from_agent=qa_agent_id,
to_agent=doc_agent.id,
skill="documentation",
task_id=task_id,
body=f"QA passed task {t.id}. PR: {t.pr_url}. Please document.",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle until next QA work arrives",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
)
@staticmethod
def _check_qa_pass_gates(
*, notes: str, has_learning: bool, evidence_inspected: bool
) -> list[str]:
"""Return list of missing gate keys; empty list if all pass."""
missing: list[str] = []
if not notes or len(notes) < settings.qa_notes_min_chars:
missing.append("qa_notes>=min")
if not has_learning:
missing.append("journal:learning")
if not evidence_inspected:
missing.append("qa_evidence_inspected")
return missing
@staticmethod
def _qa_tracing_gap(
missing: list[str], task_id: UUID, briefing: dict[str, Any]
) -> Envelope:
"""Build a tracing_gap envelope with role-appropriate hints."""
from roboco.services.gateway.remediation import (
hint_for_evidence_not_inspected,
hint_for_missing_journal_learning,
hint_for_missing_qa_notes,
)
hint_map = {
"qa_notes>=min": hint_for_missing_qa_notes(),
"journal:learning": hint_for_missing_journal_learning(),
"qa_evidence_inspected": hint_for_evidence_not_inspected(
task_id=str(task_id)
),
}
hints = [hint_map[m] for m in missing if m in hint_map]
return Envelope.tracing_gap(
missing=missing,
remediate=" ; ".join(hints),
context_briefing=briefing,
)
async def fail_review(
self, qa_agent_id: UUID, task_id: UUID, issues: list[str]
) -> Envelope:
"""QA fails the task with concrete issues; transitions to needs_revision."""
t = await self.task.get(task_id) # type: ignore[attr-defined]
if t is None:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
if t.assigned_to != qa_agent_id:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_review(task_id) first",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
if not issues:
return await self._emit_rejection( # type: ignore[attr-defined]
Envelope.invalid_state(
message="fail_review requires at least one issue",
remediate="pass issues=['<concrete actionable issue>', ...]",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
has_learning = await self.journal.has_learning_for_task( # type: ignore[attr-defined]
qa_agent_id, task_id
)
notes = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues)
missing = self._check_qa_pass_gates(
notes=notes,
has_learning=has_learning,
evidence_inspected=t.qa_evidence_inspected,
)
if missing:
return await self._emit_rejection( # type: ignore[attr-defined]
self._qa_tracing_gap(
missing,
task_id,
await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
),
agent_id=qa_agent_id,
task_id=task_id,
verb="fail_review",
)
t = await self.task.qa_fail(qa_agent_id, task_id, notes, issues) # type: ignore[attr-defined]
if t.assigned_to is not None:
await self.a2a.send( # type: ignore[attr-defined]
from_agent=qa_agent_id,
to_agent=t.assigned_to,
skill="code_review",
task_id=task_id,
body=f"QA needs changes. Issues:\n{notes}",
)
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next="idle — dev will revise and re-submit",
context_briefing=await self._briefing_for(qa_agent_id, task_id), # type: ignore[attr-defined]
)
+1 -1
View File
@@ -163,7 +163,7 @@ class ContentActions:
return Envelope.ok(
status=str(t.status),
task_id=str(t.id),
next="continue, then i_have_committed or i_am_done",
next="continue committing, or submit_for_qa when ready",
context_briefing={},
)
-1
View File
@@ -25,7 +25,6 @@ class RoleConfig:
_DEV_FLOW = (
"give_me_work",
"i_will_work_on",
"i_have_committed",
"submit_for_qa",
"i_am_done",
"i_am_blocked",
+1 -3
View File
@@ -1784,9 +1784,7 @@ class GitService(BaseService):
)
if workspace_agent_id is None and task.created_by:
workspace_agent_id = UUID(str(task.created_by))
workspace = await self.get_workspace(
project.slug, agent_id=workspace_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)
owner, repo = self._parse_github_remote(workspace)
+99 -19
View File
@@ -942,10 +942,23 @@ class TaskService(BaseService):
) -> None:
"""
Apply claim-side-effects: status transition, branch + work session + context.
On branch-creation failure, the claim fields are rolled back to
their pre-claim values so a retry starts from a clean state. Without
this, a partial failure leaves the task CLAIMED with branch_name=NULL,
and `git checkout -b` on retry fails non-idempotent (audit S-01/D-39).
"""
# Set context for QA/Documenter claims (only if not already set)
self._set_original_developer_context(task, agent)
# Snapshot for rollback on branch-creation failure.
original_status = task.status
original_assigned_to = task.assigned_to
original_claimed_by = task.claimed_by
original_claimed_at = task.claimed_at
original_heartbeat = task.last_heartbeat_at
original_claimant_id = task.active_claimant_id
now = datetime.now(UTC)
task.assigned_to = cast("Any", agent_id)
task.claimed_by = cast("Any", agent_id)
@@ -957,6 +970,11 @@ class TaskService(BaseService):
# would touch the heartbeat — leading to an unclaim/reclaim
# tight loop hammering the orchestrator.
task.last_heartbeat_at = now
# Single-claimant invariant (alembic 006): claimant_lock.try_acquire
# and trigger_filter.decide_spawn both branch on this column. Was
# declared but never written (audit D-05); now wired so the
# invariant is functional.
task.active_claimant_id = cast("Any", agent_id)
agent_role = agent.role.value if agent and agent.role else None
if task.status in self._CLAIMABLE_STATUSES:
@@ -965,7 +983,18 @@ class TaskService(BaseService):
await self.session.flush()
if not task.branch_name:
try:
await self._ensure_branch_for_task(task, agent_id)
except Exception:
# Roll back claim fields so the task is reclaimable.
task.status = original_status
task.assigned_to = original_assigned_to
task.claimed_by = original_claimed_by
task.claimed_at = original_claimed_at
task.last_heartbeat_at = original_heartbeat
task.active_claimant_id = original_claimant_id
await self.session.flush()
raise
await self.session.refresh(task)
await self._create_work_session_if_needed(task, agent_id, agent_role)
@@ -1822,21 +1851,30 @@ class TaskService(BaseService):
async def unclaim_for_reaper(self, task_id: UUID) -> None:
"""Reaper-only unclaim: skip role checks, force the row back to pending.
Bypasses the normal claim guards because the holder is provably dead
(no heartbeat past TTL) the operation is named with ``_for_reaper``
so callers cannot accidentally use it as a regular unclaim path.
Clears ``assigned_to`` and ``last_heartbeat_at`` so the next claim
starts fresh.
Routes through ``_validate_and_set_status`` (audit P2-4/D-20) so the
canonical state machine in ``enforcement/task_lifecycle.py`` records
the transition. Pre-fix this used raw UPDATE which bypassed
VALID_TRANSITIONS making the lifecycle module's invariants diverge
from production reality.
The operation is named with ``_for_reaper`` so callers cannot
accidentally use it as a regular unclaim path; uses ``agent_role=None``
because the system itself is performing the transition. Bypasses
ownership/role checks because the holder is provably dead (no
heartbeat past TTL).
"""
await self.session.execute(
update(TaskTable)
.where(TaskTable.id == task_id)
.values(
status=TaskStatus.PENDING,
assigned_to=None,
last_heartbeat_at=None,
)
)
task = await self.get(task_id)
if task is None:
return
if task.status not in (TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS):
return
try:
self._validate_and_set_status(task, TaskStatus.PENDING, None)
except TaskLifecycleError:
return
task.assigned_to = cast("Any", None)
task.last_heartbeat_at = None
task.active_claimant_id = cast("Any", None)
await self.session.flush()
async def unclaim_for_agent(
@@ -1889,6 +1927,7 @@ class TaskService(BaseService):
# _validate_and_set_status only updates `status`; clearing the
# claim is the unclaim's specific side effect.
task.assigned_to = cast("Any", None)
task.active_claimant_id = cast("Any", None)
await self.session.flush()
return task
@@ -4628,9 +4667,21 @@ class TaskService(BaseService):
return None
if task.status != expected_status:
return None
now = datetime.now(UTC)
task.assigned_to = cast("Any", agent_id)
task.claimed_by = cast("Any", agent_id)
task.claimed_at = datetime.now(UTC)
task.claimed_at = now
# Seed the heartbeat — same rationale as _finalize_claim line 959.
# `claimant_lock.is_stale` and the reaper both treat
# last_heartbeat_at IS NULL as stale; without this seed a QA/Doc
# claim is "stale" the moment it's recorded and any code that
# consults claimant_lock for awaiting_qa / awaiting_documentation
# tasks will misclassify the live claim as abandoned.
task.last_heartbeat_at = now
# Single-claimant invariant — see _finalize_claim. Same column
# used by claimant_lock + trigger_filter. Cleared by QA pass/fail
# and doc-complete when the review hand-off finishes.
task.active_claimant_id = cast("Any", agent_id)
await self.session.flush()
return task
@@ -4649,10 +4700,26 @@ class TaskService(BaseService):
) -> TaskTable | None:
"""QA passes the task (gateway-flavored wrapper of pass_qa).
Records the QA agent as the one performing the transition. The
underlying pass_qa() clears assignment so a documenter can claim.
The audit row is attributed to QA via task.claimed_by (set by
qa_claim). We assert qa_agent_id matches claimed_by so any future
divergence surfaces loudly instead of silently mis-recording the
actor (audit D-18). Clears the single-claimant lock so the
documenter can claim cleanly.
"""
del qa_agent_id # gateway already validated assignment
task = await self.get(task_id)
if task is not None:
if (
task.claimed_by is not None
and to_python_uuid(task.claimed_by) != qa_agent_id
):
self.log.warning(
"qa_pass actor mismatch",
task_id=str(task_id),
qa_agent_id=str(qa_agent_id),
claimed_by=str(task.claimed_by),
)
task.active_claimant_id = cast("Any", None)
await self.session.flush()
return await self.pass_qa(task_id, notes=notes, agent_role="qa")
async def qa_fail(
@@ -4666,14 +4733,27 @@ class TaskService(BaseService):
`notes` is the QA narrative (stored on `qa_notes`); `issues` is
appended to `dev_notes` as a checklist for the dev's revision.
Asserts the actor matches claimed_by (audit D-18).
"""
del qa_agent_id # gateway already validated assignment
task = await self.get(task_id)
if task is None:
return None
if (
task.claimed_by is not None
and to_python_uuid(task.claimed_by) != qa_agent_id
):
self.log.warning(
"qa_fail actor mismatch",
task_id=str(task_id),
qa_agent_id=str(qa_agent_id),
claimed_by=str(task.claimed_by),
)
if issues:
issue_block = "[QA ISSUES]\n" + "\n".join(f"- {i}" for i in issues)
task.dev_notes = _append_capped(task.dev_notes, issue_block)
# Clear active_claimant_id — fail_qa transitions back to
# needs_revision and reassigns to the original developer.
task.active_claimant_id = cast("Any", None)
await self.session.flush()
return await self.fail_qa(task_id, notes=notes, agent_role="qa")
+211
View File
@@ -0,0 +1,211 @@
"""Regenerate per-role verb tables from Pydantic schemas + role_config.
Audit P2-9 / D-04, D-10, D-11, D-29, D-30, D-31. Eliminates the
prompt-drift class: instead of curating verb tables in prose, derive
them from the same schemas the API enforces. When a schema changes,
re-run this script and the prompt-side tables update.
Output: ``agents/prompts/_generated/verbs.md`` one section per role.
Role prompts reference this file's section instead of duplicating
verb signatures inline.
Usage:
uv run python scripts/regenerate_verb_tables.py
The script reads from:
- ``roboco.services.gateway.role_config.ROLE_CONFIGS`` for role -> verb list
- ``roboco.api.schemas.v2.flow`` for flow verb body schemas
- ``roboco.api.schemas.v2.do`` for content tool body schemas
"""
from __future__ import annotations
import inspect
from pathlib import Path
from typing import Any, get_args, get_origin
from pydantic import BaseModel
from roboco.api.schemas.v2 import do as do_schemas
from roboco.api.schemas.v2 import flow as flow_schemas
from roboco.services.gateway.role_config import ROLE_CONFIGS
_OUT_DIR = Path(__file__).resolve().parents[1] / "agents/prompts/_generated"
_OUT = _OUT_DIR / "verbs.md"
# Map verb name -> request schema class. Verbs without a schema (e.g. an
# empty-body endpoint) get an empty body description. Falls back via name
# similarity for verbs whose schema name diverges from the verb (e.g.
# ``pass`` is a Python keyword so the schema is ``PassReviewRequest``).
_VERB_TO_SCHEMA: dict[str, type[BaseModel]] = {
# Flow — dev
"give_me_work": flow_schemas.GiveMeWorkRequest,
"i_will_work_on": flow_schemas.IWillWorkOnRequest,
"submit_for_qa": flow_schemas.SubmitForQaRequest,
"i_am_done": flow_schemas.IAmDoneRequest,
"i_am_blocked": flow_schemas.IAmBlockedRequest,
"unclaim": flow_schemas.UnclaimRequest,
"resume": flow_schemas.ResumeRequest,
"i_am_idle": flow_schemas.IAmIdleRequest,
# Flow — qa
"claim_review": flow_schemas.ClaimReviewRequest,
"pass": flow_schemas.PassReviewRequest,
"fail": flow_schemas.FailReviewRequest,
# Flow — doc
"claim_doc_task": flow_schemas.ClaimDocTaskRequest,
"i_documented": flow_schemas.IDocumentedRequest,
# Flow — pm
"triage": flow_schemas.TriageRequest,
"triage_all": flow_schemas.TriageRequest,
"unblock": flow_schemas.UnblockRequest,
"complete": flow_schemas.CompleteRequest,
"escalate_up": flow_schemas.EscalateUpRequest,
"escalate_to_ceo": flow_schemas.EscalateToCeoRequest,
}
def _flow_extra_schemas() -> dict[str, type[BaseModel]]:
"""Pick up schemas defined in flow.py that aren't in the explicit map."""
extras: dict[str, type[BaseModel]] = {}
for name, obj in inspect.getmembers(flow_schemas, inspect.isclass):
if not issubclass(obj, BaseModel) or obj is BaseModel:
continue
# Heuristic: ``IWillPlanRequest`` -> ``i_will_plan``.
if not name.endswith("Request"):
continue
verb_camel = name[: -len("Request")]
verb = _camel_to_snake(verb_camel)
extras.setdefault(verb, obj)
return extras
def _do_schemas() -> dict[str, type[BaseModel]]:
"""Schemas from do.py keyed by content-tool name."""
out: dict[str, type[BaseModel]] = {}
for name, obj in inspect.getmembers(do_schemas, inspect.isclass):
if not issubclass(obj, BaseModel) or obj is BaseModel:
continue
if not name.endswith("Request"):
continue
verb_camel = name[: -len("Request")]
verb = _camel_to_snake(verb_camel)
out.setdefault(verb, obj)
return out
def _camel_to_snake(name: str) -> str:
out: list[str] = []
for i, ch in enumerate(name):
if i and ch.isupper():
out.append("_")
out.append(ch.lower())
return "".join(out)
def _format_field(name: str, field_info: Any) -> str:
"""Render one field as ``name: type [= default]``."""
annot = field_info.annotation
type_str = _annot_str(annot)
if field_info.is_required():
return f"{name}: {type_str}"
default = field_info.default
return f"{name}: {type_str} = {default!r}"
def _annot_str(annot: Any) -> str:
if annot is None or annot is type(None):
return "None"
origin = get_origin(annot)
if origin is None:
return getattr(annot, "__name__", str(annot))
args = get_args(annot)
if origin in (list,):
inner = ", ".join(_annot_str(a) for a in args)
return f"list[{inner}]"
# Optional / Union
rendered = " | ".join(_annot_str(a) for a in args)
return rendered
def _signature_for_schema(schema: type[BaseModel]) -> str:
"""Render the schema's required + optional fields as a Python-like signature."""
fields = schema.model_fields
if not fields:
return "()"
parts = [_format_field(name, info) for name, info in fields.items()]
return "(" + ", ".join(parts) + ")"
def _render_role_section(role: str) -> str:
cfg = ROLE_CONFIGS[role]
flow_schemas_map = {**_VERB_TO_SCHEMA, **_flow_extra_schemas()}
do_schemas_map = _do_schemas()
lines: list[str] = [f"## {role}", ""]
lines.append("### Flow verbs")
lines.append("")
lines.append("| Verb | Body schema |")
lines.append("|------|-------------|")
for verb in cfg.flow_tools:
schema = flow_schemas_map.get(verb)
sig = (
_signature_for_schema(schema)
if schema is not None
else "(unknown — no Pydantic schema)"
)
lines.append(f"| `{verb}` | `{verb}{sig}` |")
lines.append("")
lines.append("### Content (do) tools")
lines.append("")
lines.append("| Tool | Body schema |")
lines.append("|------|-------------|")
for tool in cfg.do_tools:
schema = do_schemas_map.get(tool)
sig = _signature_for_schema(schema) if schema is not None else "(see do_server)"
lines.append(f"| `{tool}` | `{tool}{sig}` |")
lines.append("")
return "\n".join(lines)
def main() -> None:
sections = [
"<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->",
"<!-- Source: services/gateway/role_config.py + api/schemas/v2/. -->",
"",
"# Per-role verb shapes (autogenerated)",
"",
"Run `uv run python scripts/regenerate_verb_tables.py` after changing",
"any role config or schema. Role prompts reference this file's sections",
"as the source of truth for verb signatures.",
"",
]
for role in ROLE_CONFIGS:
sections.append(_render_role_section(role))
_OUT_DIR.mkdir(parents=True, exist_ok=True)
_OUT.write_text("\n".join(sections) + "\n")
print(f"wrote {_OUT.relative_to(_OUT.parents[2])}")
# Also write per-role files so the prompt composer can include each
# role's verb table directly without parsing a multi-section document.
for role in ROLE_CONFIGS:
per_role = _OUT_DIR / f"{role}.md"
header = (
"<!-- AUTOGENERATED by scripts/regenerate_verb_tables.py. -->\n"
"<!-- Per-role verb signatures, derived from Pydantic schemas. -->\n\n"
"## Verbs available to you (autogenerated source of truth)\n\n"
)
body = _render_role_section(role)
# Strip the leading `## <role>` header — it's redundant once the
# composer injects this under a role prompt that already names the
# role in its identity section.
body_lines = body.splitlines()
if body_lines and body_lines[0].startswith("## "):
body_lines = body_lines[1:]
while body_lines and not body_lines[0].strip():
body_lines = body_lines[1:]
per_role.write_text(header + "\n".join(body_lines) + "\n")
print(f"wrote {per_role.relative_to(_OUT.parents[2])}")
if __name__ == "__main__":
main()
@@ -0,0 +1,349 @@
"""Real-DB end-to-end test driving the gateway through the full lifecycle.
Audit deliverable P2-1: the missing integration test that would have
caught every smoking gun in the 2026-05-04 audit. Drives a single task
from pending completed using a real `db_session` (Postgres-backed
fixture from the top-level conftest), a real `Choreographer`, and a
real `TaskService`. Git is replaced with a deterministic stub
(`_StubGit`) that mutates the same task row the choreographer reads,
so PR/commit state is consistent between the choreographer and the
test's assertions. Journal/A2A/audit/evidence are mocked because they
don't gate the lifecycle paths under test.
When extended to all roles, this test catches:
- URL prefix mismatch (route-level coverage in test_v2_role_dep)
- i_will_work_on AttributeError on None (claim start sequence is real)
- heartbeat seeding (reaper cutoff)
- active_claimant_id wired (single-claimant invariant)
- i_am_done auto-runs submit_verification (P1-3)
- QA pass clears active_claimant_id (P1-4)
- branch creation atomicity rollback (P0-7)
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.task import TaskService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_BRANCH = "feature/backend/healthz"
_PR_NUMBER = 8
_PR_URL = "https://github.com/example/life/pull/8"
class _StubGit:
"""Deterministic GitService stub.
Mutates the test's TaskTable row directly to mirror what the real
`git.create_pr` / `git.commit` do via `_record_pr_atomically` and
`_workspace_for_branch`. The choreographer reads pr_number/commits
off the task object keeping them in sync here means the gates
behave the same as production without any disk or network I/O.
"""
def __init__(self, session: Any, task: TaskTable) -> None:
self._session = session
self._task = task
async def commit(
self,
*,
branch_name: str,
message: str,
task_id: UUID,
files: list[str] | None = None,
) -> dict[str, Any]:
del branch_name, files
sha = uuid4().hex[:40]
commits = list(self._task.commits or [])
commits.append({"sha": sha, "message": message, "task_id": str(task_id)})
self._task.commits = commits # type: ignore[assignment]
await self._session.flush()
return {
"sha": sha,
"message": message,
"files_changed": 1,
"insertions": 1,
"deletions": 0,
}
async def push_branch(self, branch_name: str) -> tuple[str, int]:
del branch_name
return ("ok", 0)
async def create_pr(
self, branch_name: str, *, parent: str, is_root_pr: bool
) -> dict[str, Any]:
del branch_name, parent
self._task.pr_number = _PR_NUMBER
self._task.pr_url = _PR_URL
await self._session.flush()
return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr}
async def diff(self, *, branch_name: str) -> str: # noqa: ARG002
return "stub diff"
async def pr_target(self, pr_number: int) -> str: # noqa: ARG002
return "main"
async def pr_merge(self, **kwargs: Any) -> dict[str, Any]:
del kwargs
return {"merged": True, "sha": uuid4().hex[:40]}
def _mock_evidence_repo() -> Any:
repo = AsyncMock()
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(repo, method).return_value = []
return repo
def _mock_journal_with_reflect() -> Any:
"""Journal stub that reports reflect/learning/decision entries present."""
journal = AsyncMock()
journal.has_reflect_for_task.return_value = True
journal.has_learning_for_task.return_value = True
journal.has_decision_for_task.return_value = True
return journal
def _mock_work_session() -> Any:
"""WorkSession stub: empty file list, no unpushed commits."""
ws = AsyncMock()
ws.files_changed.return_value = ["roboco/api/routes/health.py"]
ws.has_unpushed_commits.return_value = False
return ws
@pytest_asyncio.fixture
async def lifecycle_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict[str, Any]]:
"""Seed a project + dev agent + a single pending task ready to claim."""
system_agent = AgentTable(
id=uuid4(),
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system_agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="Lifecycle Test Project",
slug=f"life-{uuid4().hex[:8]}",
git_url="https://github.com/example/life.git",
default_branch="main",
protected_branches=["main"],
assigned_cell=Team.BACKEND,
created_by=system_agent.id,
is_active=True,
)
db_session.add(project)
await db_session.flush()
dev_agent = AgentTable(
id=uuid4(),
name="BE Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=["python"],
permissions={},
metrics={},
)
qa_agent = AgentTable(
id=uuid4(),
name="BE QA",
slug=f"be-qa-{uuid4().hex[:8]}",
role=AgentRole.QA,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="qa",
capabilities=["review"],
permissions={},
metrics={},
)
db_session.add_all([dev_agent, qa_agent])
await db_session.flush()
task = TaskTable(
id=uuid4(),
title="Add /healthz endpoint",
description="Return 200 OK from /healthz",
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=project.id,
created_by=system_agent.id,
assigned_to=dev_agent.id,
branch_name=_BRANCH,
acceptance_criteria=["Returns 200", "Includes timestamp"],
acceptance_criteria_status=[
{"criterion": "Returns 200", "referencing_artifact_id": "stub"},
{"criterion": "Includes timestamp", "referencing_artifact_id": "stub"},
],
)
db_session.add(task)
await db_session.flush()
yield {
"project": project,
"dev_agent": dev_agent,
"qa_agent": qa_agent,
"task": task,
}
@pytest.mark.asyncio
async def test_dev_can_claim_pending_task_via_gateway(
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
) -> None:
"""give_me_work → i_will_work_on lands the task in in_progress.
Verifies in one shot: P0-2 (None-handling), P0-3 (heartbeat seed),
P0-7 (branch atomicity), P1-4 (active_claimant_id wired).
"""
task = lifecycle_setup["task"]
dev_agent = lifecycle_setup["dev_agent"]
task_service = TaskService(db_session)
deps = ChoreographerDeps(
task=task_service,
work_session=_mock_work_session(),
git=_StubGit(db_session, task),
a2a=AsyncMock(),
journal=_mock_journal_with_reflect(),
audit=AsyncMock(),
evidence_repo=_mock_evidence_repo(),
)
c = Choreographer(deps)
env = await c.i_will_work_on(dev_agent.id, task.id, plan="add the route")
assert env.error is None, f"claim failed: {env.message}"
assert env.status == "in_progress"
refreshed = await task_service.get(task.id)
assert refreshed is not None
assert str(refreshed.status) == "in_progress"
assert refreshed.assigned_to == dev_agent.id
assert refreshed.last_heartbeat_at is not None, "P0-3: heartbeat seed"
assert refreshed.active_claimant_id == dev_agent.id, "P1-4: claim lock"
@pytest.mark.asyncio
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.
Drives the full developer-side closure path. Verifies:
- submit_for_qa 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
"""
task = lifecycle_setup["task"]
dev_agent = lifecycle_setup["dev_agent"]
task_service = TaskService(db_session)
stub_git = _StubGit(db_session, task)
deps = ChoreographerDeps(
task=task_service,
work_session=_mock_work_session(),
git=stub_git,
a2a=AsyncMock(),
journal=_mock_journal_with_reflect(),
audit=AsyncMock(),
evidence_repo=_mock_evidence_repo(),
)
c = Choreographer(deps)
# 1. Claim
env = await c.i_will_work_on(dev_agent.id, task.id, plan="add the route")
assert env.error is None
assert env.status == "in_progress"
# 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).
await stub_git.commit(
branch_name=_BRANCH,
message=f"[{str(task.id)[:8]}] feat(api): add /healthz",
task_id=task.id,
)
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}"
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"
# 4. i_am_done — auto-runs in_progress → verifying → awaiting_qa.
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
assert env.error is None, f"i_am_done failed: {env.message}"
assert env.status == "awaiting_qa", (
"P1-3: i_am_done must auto-run submit_verification + submit_qa"
)
final = await task_service.get(task.id)
assert final is not None
assert str(final.status) == "awaiting_qa"
assert final.self_verified is True, "P1-3: self_verified set by auto-verify"
# TODO P2-1 follow-up — extend the chain past awaiting_qa:
# - QA: claim_review → pass → awaiting_documentation
# - Documenter: claim_doc_task → i_documented → awaiting_pm_review
# - Cell PM: complete on the leaf → completed (or submit_up to a parent)
# - Main PM: complete on the root → awaiting_ceo_approval
# Each stage needs the role's agent seeded (lifecycle_setup already has
# dev + qa; add doc + cell_pm + main_pm) plus journal entries with the
# right scope (pass needs journal:learning; complete needs journal:decision).
# The _StubGit class above already covers commit/push/pr_create/pr_target
# /pr_merge for the merge stages.
@@ -208,29 +208,29 @@ async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
# 1. Dev: give_me_work -> i_will_work_on -> note -> i_am_done
# ------------------------------------------------------------------
r = client.post(
"/api/v2/flow/dev/give_me_work",
headers={"X-Agent-ID": dev_id},
"/api/v2/flow/developer/give_me_work",
headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={},
)
assert r.status_code == _HTTP_200
r = client.post(
"/api/v2/flow/dev/i_will_work_on",
headers={"X-Agent-ID": dev_id},
"/api/v2/flow/developer/i_will_work_on",
headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={"task_id": str(uuid4()), "plan": "edit x then y"},
)
assert r.json()["status"] == "in_progress"
r = client.post(
"/api/v2/do/note",
headers={"X-Agent-ID": dev_id},
headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={"text": "Reflected: did the work as planned.", "scope": "reflect"},
)
assert r.json()["error"] is None
r = client.post(
"/api/v2/flow/dev/i_am_done",
headers={"X-Agent-ID": dev_id},
"/api/v2/flow/developer/i_am_done",
headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={"task_id": str(uuid4()), "notes": "all done"},
)
assert r.json()["status"] == "awaiting_qa"
@@ -242,14 +242,14 @@ async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
# ------------------------------------------------------------------
r = client.post(
"/api/v2/flow/qa/claim_review",
headers={"X-Agent-ID": qa_id},
headers={"X-Agent-ID": qa_id, "X-Agent-Role": "qa"},
json={"task_id": str(uuid4())},
)
assert r.json()["evidence"]["pr_number"] == _PR_NUMBER
r = client.post(
"/api/v2/do/note",
headers={"X-Agent-ID": qa_id},
headers={"X-Agent-ID": qa_id, "X-Agent-Role": "developer"},
json={
"text": "Reviewed; all acceptance criteria addressed.",
"scope": "learning",
@@ -263,7 +263,7 @@ async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
)
r = client.post(
"/api/v2/flow/qa/pass",
headers={"X-Agent-ID": qa_id},
headers={"X-Agent-ID": qa_id, "X-Agent-Role": "qa"},
json={"task_id": str(uuid4()), "notes": long_notes},
)
assert r.json()["status"] == "awaiting_documentation"
@@ -273,14 +273,14 @@ async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
# ------------------------------------------------------------------
r = client.post(
"/api/v2/flow/documenter/claim_doc_task",
headers={"X-Agent-ID": doc_id},
headers={"X-Agent-ID": doc_id, "X-Agent-Role": "documenter"},
json={"task_id": str(uuid4())},
)
assert r.status_code == _HTTP_200
r = client.post(
"/api/v2/flow/documenter/i_documented",
headers={"X-Agent-ID": doc_id},
headers={"X-Agent-ID": doc_id, "X-Agent-Role": "documenter"},
json={
"task_id": str(uuid4()),
"notes": "Wrote backend/guides/feature-x.md covering usage and config.",
@@ -294,14 +294,14 @@ async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
# ------------------------------------------------------------------
r = client.post(
"/api/v2/do/note",
headers={"X-Agent-ID": cell_pm_id},
headers={"X-Agent-ID": cell_pm_id, "X-Agent-Role": "developer"},
json={"text": "Decision: approve and merge.", "scope": "decision"},
)
assert r.json()["error"] is None
r = client.post(
"/api/v2/flow/cell_pm/complete",
headers={"X-Agent-ID": cell_pm_id},
headers={"X-Agent-ID": cell_pm_id, "X-Agent-Role": "cell_pm"},
json={"task_id": str(uuid4()), "notes": "Approved and merged"},
)
assert r.json()["status"] == "completed"
@@ -311,14 +311,14 @@ async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
# ------------------------------------------------------------------
r = client.post(
"/api/v2/do/note",
headers={"X-Agent-ID": main_pm_id},
headers={"X-Agent-ID": main_pm_id, "X-Agent-Role": "developer"},
json={"text": "Root task ready for prod.", "scope": "decision"},
)
assert r.json()["error"] is None
r = client.post(
"/api/v2/flow/main_pm/complete",
headers={"X-Agent-ID": main_pm_id},
headers={"X-Agent-ID": main_pm_id, "X-Agent-Role": "main_pm"},
json={"task_id": str(uuid4()), "notes": "Ready for prod"},
)
assert r.json()["status"] == "awaiting_ceo_approval"
@@ -0,0 +1,120 @@
"""State machine invariant checks (audit P2-6).
Originally specced as hypothesis-driven, but hypothesis isn't a project
dependency, so the same invariants are asserted via deterministic
exhaustive enumeration plus a bounded random walk. The intent is identical:
produce a structural sweep that catches the audit's identified risks
(orphan states, transitions writing fields outside the lifecycle module,
terminal states being mistakenly listed as escape points).
Invariants checked:
1. Every state declared in ``VALID_TRANSITIONS`` appears as either a
source or a target no entries that nothing transitions into and
nothing transitions out of.
2. Every non-terminal state has at least one outgoing transition.
3. Terminal states (``completed``, ``cancelled``) have no outgoing
transitions.
4. ``is_terminal_state`` is consistent with the empty-transition list
in ``VALID_TRANSITIONS``.
5. Every state is reachable from the initial state ``backlog`` (BFS).
6. Bounded random walk from ``backlog``: any sequence of valid
transitions stays within the declared state set; never crosses into
undeclared states; terminal states absorb (no further transitions).
"""
from __future__ import annotations
import random
from roboco.enforcement.task_lifecycle import (
VALID_TRANSITIONS,
get_valid_transitions,
is_terminal_state,
)
_INITIAL_STATE = "backlog"
_TERMINAL_STATES = {"completed", "cancelled"}
_DECLARED_STATES = set(VALID_TRANSITIONS.keys())
def test_no_orphan_states() -> None:
"""Invariant 1 — every state is reachable + has an exit if non-terminal."""
targets: set[str] = set()
for outgoing in VALID_TRANSITIONS.values():
targets.update(outgoing)
targets.add(_INITIAL_STATE) # initial state has no inbound by convention
sources = {state for state, outs in VALID_TRANSITIONS.items() if outs}
# Every declared state must appear as a target or be terminal.
orphan_targets = _DECLARED_STATES - targets
assert not orphan_targets, f"states with no inbound transition: {orphan_targets}"
# Every non-terminal declared state must have outbound transitions.
orphan_sources = (_DECLARED_STATES - sources) - _TERMINAL_STATES
assert not orphan_sources, (
f"non-terminal states with no outbound transitions: {orphan_sources}"
)
def test_terminal_states_have_no_exits() -> None:
"""Invariant 3 — terminal states must not list any outgoing transitions."""
for state in _TERMINAL_STATES:
assert state in VALID_TRANSITIONS, f"terminal state {state} not declared"
assert VALID_TRANSITIONS[state] == [], (
f"terminal state {state} declares outgoing transitions: "
f"{VALID_TRANSITIONS[state]}"
)
def test_is_terminal_state_consistent_with_transitions() -> None:
"""Invariant 4 — is_terminal_state(s) iff VALID_TRANSITIONS[s] is empty."""
for state, outgoing in VALID_TRANSITIONS.items():
assert is_terminal_state(state) == (len(outgoing) == 0), (
f"is_terminal_state({state!r})={is_terminal_state(state)} "
f"but outgoing transitions = {outgoing}"
)
def test_every_state_reachable_from_initial() -> None:
"""Invariant 5 — BFS from backlog covers every declared state."""
visited: set[str] = set()
frontier: list[str] = [_INITIAL_STATE]
while frontier:
state = frontier.pop()
if state in visited:
continue
visited.add(state)
for nxt in VALID_TRANSITIONS.get(state, []):
if nxt not in visited:
frontier.append(nxt)
unreachable = _DECLARED_STATES - visited
assert not unreachable, f"states unreachable from {_INITIAL_STATE!r}: {unreachable}"
def test_random_walks_stay_within_declared_states() -> None:
"""Invariant 6 — bounded random walks from backlog never leave the declared set."""
rng = random.Random(20260504)
walks = 100
max_steps = 50
for _ in range(walks):
state = _INITIAL_STATE
for _ in range(max_steps):
assert state in _DECLARED_STATES, f"walked into undeclared state {state!r}"
outs = get_valid_transitions(state)
if not outs:
# Terminal — walk stops.
assert is_terminal_state(state), (
f"non-terminal state {state!r} has no transitions"
)
break
state = rng.choice(outs)
def test_no_self_loops() -> None:
"""Bonus invariant — no state may transition to itself."""
for state, outs in VALID_TRANSITIONS.items():
assert state not in outs, (
f"state {state!r} has a self-loop in VALID_TRANSITIONS"
)
+12 -46
View File
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/flow/dev/* endpoints.
"""Unit tests for /api/v2/flow/developer/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked.
@@ -40,13 +40,13 @@ def _build_app(mock_choreographer: MagicMock) -> FastAPI:
@pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None:
"""POST /api/v2/flow/dev/give_me_work returns 200 with envelope shape."""
"""POST /api/v2/flow/developer/give_me_work returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/give_me_work",
"/api/v2/flow/developer/give_me_work",
json={},
headers=_HEADERS,
)
@@ -59,7 +59,7 @@ async def test_give_me_work_returns_envelope() -> None:
@pytest.mark.asyncio
async def test_i_will_work_on_dispatches_task_id() -> None:
"""POST /api/v2/flow/dev/i_will_work_on forwards task_id and plan."""
"""POST /api/v2/flow/developer/i_will_work_on forwards task_id and plan."""
mock_chore = MagicMock()
mock_chore.i_will_work_on = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
@@ -67,7 +67,7 @@ async def test_i_will_work_on_dispatches_task_id() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_will_work_on",
"/api/v2/flow/developer/i_will_work_on",
json={"task_id": _TASK_ID, "plan": "implement the feature"},
headers=_HEADERS,
)
@@ -82,29 +82,9 @@ async def test_i_will_work_on_dispatches_task_id() -> None:
assert call_args.args[2] == "implement the feature"
@pytest.mark.asyncio
async def test_i_have_committed_dispatches_message() -> None:
"""POST /api/v2/flow/dev/i_have_committed forwards commit message."""
mock_chore = MagicMock()
mock_chore.i_have_committed = AsyncMock(
return_value=_make_envelope(status="in_progress")
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_have_committed",
json={"message": "add auth endpoint"},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_chore.i_have_committed.assert_awaited_once()
assert mock_chore.i_have_committed.call_args.args[1] == "add auth endpoint"
@pytest.mark.asyncio
async def test_i_am_done_dispatches_task_and_notes() -> None:
"""POST /api/v2/flow/dev/i_am_done forwards task_id and notes."""
"""POST /api/v2/flow/developer/i_am_done forwards task_id and notes."""
mock_chore = MagicMock()
mock_chore.i_am_done = AsyncMock(
return_value=_make_envelope(status="awaiting_qa", task_id=_TASK_ID)
@@ -112,7 +92,7 @@ async def test_i_am_done_dispatches_task_and_notes() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_am_done",
"/api/v2/flow/developer/i_am_done",
json={"task_id": _TASK_ID, "notes": "all tests pass"},
headers=_HEADERS,
)
@@ -125,7 +105,7 @@ async def test_i_am_done_dispatches_task_and_notes() -> None:
@pytest.mark.asyncio
async def test_i_am_blocked_dispatches_reason() -> None:
"""POST /api/v2/flow/dev/i_am_blocked forwards task_id and reason."""
"""POST /api/v2/flow/developer/i_am_blocked forwards task_id and reason."""
mock_chore = MagicMock()
mock_chore.i_am_blocked = AsyncMock(
return_value=_make_envelope(status="blocked", task_id=_TASK_ID)
@@ -133,7 +113,7 @@ async def test_i_am_blocked_dispatches_reason() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_am_blocked",
"/api/v2/flow/developer/i_am_blocked",
json={"task_id": _TASK_ID, "reason": "waiting for design spec"},
headers=_HEADERS,
)
@@ -145,13 +125,13 @@ async def test_i_am_blocked_dispatches_reason() -> None:
@pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None:
"""POST /api/v2/flow/dev/i_am_idle delegates to Choreographer.i_am_idle."""
"""POST /api/v2/flow/developer/i_am_idle delegates to Choreographer.i_am_idle."""
mock_chore = MagicMock()
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_am_idle",
"/api/v2/flow/developer/i_am_idle",
json={},
headers=_HEADERS,
)
@@ -162,27 +142,13 @@ async def test_i_am_idle_dispatches_agent_id() -> None:
mock_chore.i_am_idle.assert_awaited_once()
def test_i_have_committed_rejects_empty_message() -> None:
"""POST i_have_committed rejects empty message (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_have_committed",
json={"message": ""},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_422
def test_i_am_blocked_rejects_empty_reason() -> None:
"""POST i_am_blocked rejects empty reason (min_length=1)."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/dev/i_am_blocked",
"/api/v2/flow/developer/i_am_blocked",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
+40 -2
View File
@@ -27,6 +27,9 @@ from __future__ import annotations
import asyncio
import importlib
import json
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
@@ -95,7 +98,7 @@ def test_route_stamps_request_correlation_id_onto_envelope() -> None:
app, _ = _build_app()
client = TestClient(app)
r = client.post(
"/api/v2/flow/dev/give_me_work",
"/api/v2/flow/developer/give_me_work",
json={},
headers={**_DEV_AGENT_HEADERS, "X-Correlation-ID": "trace-xyz"},
)
@@ -109,7 +112,7 @@ def test_route_stamps_generated_correlation_id_when_header_missing() -> None:
app, _ = _build_app()
client = TestClient(app)
r = client.post(
"/api/v2/flow/dev/give_me_work",
"/api/v2/flow/developer/give_me_work",
json={},
headers=_DEV_AGENT_HEADERS,
)
@@ -132,10 +135,45 @@ def _reload_mcp_module(monkeypatch: pytest.MonkeyPatch, dotted: str) -> ModuleTy
import; we have to re-import after monkey-patching so the test sees
the patched values. The reload itself is the lazy import keeping
importlib at the top-level keeps PLC0415 happy.
Also writes a stub manifest file and points the MCP server at it,
since both servers now refuse to register any tools without one
(audit P0-5 / D-12).
"""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
manifest_path = Path(tempfile.mkdtemp()) / "tool-manifest.json"
manifest_path.write_text(
json.dumps(
{
"agent_id": "00000000-0000-0000-0000-000000000001",
"role": "developer",
"team": "backend",
"workspace_path": "/tmp/test",
"flow_tools": [
"give_me_work",
"i_will_work_on",
"submit_for_qa",
"i_am_done",
"i_am_blocked",
"unclaim",
"resume",
"i_am_idle",
],
"do_tools": ["commit", "note", "say", "dm", "evidence"],
"read_tools": [],
"write_tools": [],
"bash_allowed": True,
"subagent_allowed": False,
"subagent_model": None,
"env": {},
}
)
)
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
module = importlib.import_module(dotted)
return importlib.reload(module)
+4 -4
View File
@@ -34,7 +34,7 @@ def _build_app() -> FastAPI:
def test_dev_route_rejects_qa_role() -> None:
client = TestClient(_build_app())
r = client.post(
"/api/v2/flow/dev/give_me_work",
"/api/v2/flow/developer/give_me_work",
json={},
headers={
"X-Agent-ID": "00000000-0000-0000-0000-000000000001",
@@ -48,7 +48,7 @@ def test_dev_route_rejects_qa_role() -> None:
def test_dev_route_accepts_developer_role() -> None:
client = TestClient(_build_app())
r = client.post(
"/api/v2/flow/dev/give_me_work",
"/api/v2/flow/developer/give_me_work",
json={},
headers={
"X-Agent-ID": "00000000-0000-0000-0000-000000000001",
@@ -62,7 +62,7 @@ def test_dev_route_accepts_developer_role() -> None:
def test_dev_route_accepts_developer_role_case_insensitive() -> None:
client = TestClient(_build_app())
r = client.post(
"/api/v2/flow/dev/give_me_work",
"/api/v2/flow/developer/give_me_work",
json={},
headers={
"X-Agent-ID": "00000000-0000-0000-0000-000000000001",
@@ -75,7 +75,7 @@ def test_dev_route_accepts_developer_role_case_insensitive() -> None:
def test_dev_route_rejects_missing_role_header() -> None:
client = TestClient(_build_app())
r = client.post(
"/api/v2/flow/dev/give_me_work",
"/api/v2/flow/developer/give_me_work",
json={},
headers={"X-Agent-ID": "00000000-0000-0000-0000-000000000001"},
)
+5 -61
View File
@@ -96,64 +96,6 @@ async def test_pm_cannot_execute_code_writes_audit_row() -> None:
assert args.kwargs["details"]["reason"] == "not_authorized"
# ---------------------------------------------------------------------------
# tracing_gap path: i_have_committed with no plan
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_have_committed_missing_plan_writes_audit_row() -> None:
"""Tracing-gap rejection (missing plan) should also be audited."""
aid = uuid4()
tid = uuid4()
task_with_no_plan = MagicMock(
id=tid,
status="in_progress",
assigned_to=aid,
plan=None,
)
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = task_with_no_plan
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.i_have_committed(aid, "wip")
assert env.error == "tracing_gap"
audit_svc.log_event.assert_awaited()
args = audit_svc.log_event.await_args
assert args.kwargs["event_type"] == "gateway.rejected"
assert args.kwargs["details"]["verb"] == "i_have_committed"
assert args.kwargs["details"]["reason"] == "tracing_gap"
assert "plan" in args.kwargs["details"]["missing"]
# ---------------------------------------------------------------------------
# invalid_state path: i_have_committed with no active task
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_have_committed_no_active_task_writes_audit_row() -> None:
"""invalid_state rejection (no active task) is audited."""
aid = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
audit_svc = AsyncMock()
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
env = await c.i_have_committed(aid, "wip")
assert env.error == "invalid_state"
audit_svc.log_event.assert_awaited()
args = audit_svc.log_event.await_args
assert args.kwargs["event_type"] == "gateway.rejected"
assert args.kwargs["details"]["verb"] == "i_have_committed"
assert args.kwargs["details"]["reason"] == "invalid_state"
# ---------------------------------------------------------------------------
# not_found path: unknown task id
# ---------------------------------------------------------------------------
@@ -212,15 +154,17 @@ async def test_successful_verb_does_not_write_audit_row() -> None:
async def test_audit_log_event_failure_does_not_propagate() -> None:
"""If log_event raises, the verb still returns the rejection envelope."""
aid = uuid4()
tid = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
# Unknown task id triggers not_found rejection on i_am_done.
task_svc.get.return_value = None
audit_svc = AsyncMock()
audit_svc.log_event.side_effect = RuntimeError("audit DB down")
deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps)
# Must not raise; the rejection envelope should still come back.
env = await c.i_have_committed(aid, "wip")
env = await c.i_am_done(aid, tid, notes="x")
assert env.error == "invalid_state"
assert env.error == "not_found"
audit_svc.log_event.assert_awaited()
+3 -183
View File
@@ -210,189 +210,9 @@ async def test_i_will_work_on_invalid_state_returns_invalid_state() -> None:
assert "completed" in body["message"]
@pytest.mark.asyncio
async def test_i_have_committed_records_progress() -> None:
agent_id = uuid4()
task_id = uuid4()
active = MagicMock(
id=task_id, status="in_progress", assigned_to=agent_id, plan={"x": 1}
)
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = active
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_have_committed(agent_id, "feat(api): add /healthz endpoint")
assert env.error is None
task_svc.add_progress.assert_awaited_once_with(
task_id, agent_id, "feat(api): add /healthz endpoint"
)
@pytest.mark.asyncio
async def test_i_have_committed_no_active_task_returns_invalid_state() -> None:
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_have_committed(agent_id, "feat: x")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "give_me_work" in body["remediate"]
@pytest.mark.asyncio
async def test_i_have_committed_no_plan_returns_tracing_gap() -> None:
agent_id = uuid4()
task_id = uuid4()
active = MagicMock(
id=task_id, status="in_progress", assigned_to=agent_id, plan=None
)
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = active
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_have_committed(agent_id, "feat: x")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "plan" in body["missing"]
task_svc.add_progress.assert_not_awaited()
@pytest.mark.asyncio
async def test_i_am_done_with_catchup_full_chain() -> None:
"""The catch-up convenience verb auto-runs verify/push/PR/submit_qa.
Strict ``i_am_done`` requires the dev to have done these steps already
(Gate Set E). When the dev wants the gateway to drive the chain, they
call the explicit catch-up verb.
"""
agent_id = uuid4()
task_id = uuid4()
branch = "feature/backend/abc--def"
ws_id = uuid4()
initial = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
plan={"x": 1},
branch_name=branch,
work_session_id=ws_id,
self_verified=False,
pr_number=None,
pr_url=None,
team="backend",
progress_updates=[{"message": "did x"}],
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "c1"}
],
commits=[],
documents=[],
dev_notes="",
)
after_verify = MagicMock(
id=task_id,
status="verifying",
assigned_to=agent_id,
plan={"x": 1},
branch_name=branch,
work_session_id=ws_id,
self_verified=True,
pr_number=None,
pr_url=None,
team="backend",
progress_updates=[{"message": "did x"}],
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "c1"}
],
commits=[],
documents=[],
dev_notes="",
)
after_pr = MagicMock(
id=task_id,
status="verifying",
assigned_to=agent_id,
plan={"x": 1},
branch_name=branch,
work_session_id=ws_id,
self_verified=True,
pr_number=8,
pr_url="https://x/pr/8",
team="backend",
progress_updates=[{"message": "did x"}],
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "c1"}
],
commits=[],
documents=[],
dev_notes="",
)
after_submit = MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=agent_id,
plan={"x": 1},
branch_name=branch,
work_session_id=ws_id,
self_verified=True,
pr_number=8,
pr_url="https://x/pr/8",
team="backend",
progress_updates=[{"message": "did x"}],
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "c1"}
],
commits=[],
documents=[],
dev_notes="",
)
task_svc = AsyncMock()
task_svc.get.side_effect = [initial, after_pr] # initial fetch + post-PR refresh
task_svc.submit_verification.return_value = after_verify
task_svc.submit_qa.return_value = after_submit
task_svc.qa_agent_for_team.return_value = MagicMock(
id=uuid4(), skills=[{"id": "code_review"}]
)
work_svc = AsyncMock()
work_svc.has_unpushed_commits.return_value = True
work_svc.files_changed.return_value = ["README.md"]
git_svc = AsyncMock()
git_svc.create_pr.return_value = {"pr_number": 8, "pr_url": "https://x/pr/8"}
a2a_svc = AsyncMock()
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(
task=task_svc,
work_session=work_svc,
git=git_svc,
a2a=a2a_svc,
journal=journal_svc,
)
deps.evidence_repo.journal_highlights_for_task.return_value = []
c = Choreographer(deps)
env = await c.i_am_done_with_catchup(agent_id, task_id, "all done")
assert env.error is None
assert env.status == "awaiting_qa"
git_svc.push_branch.assert_awaited_once_with(branch)
git_svc.create_pr.assert_awaited_once()
a2a_svc.send.assert_awaited_once()
body = env.as_dict()
assert body["evidence"]["pr_url"] == "https://x/pr/8"
# test_i_am_done_with_catchup_full_chain removed (audit P2-5/D-16):
# i_am_done_with_catchup verb deleted. submit_for_qa now does push + PR
# explicitly; i_am_done auto-runs submit_verification + submit_qa.
@pytest.mark.asyncio
@@ -78,28 +78,44 @@ def _ready_task(task_id: Any, agent_id: Any) -> MagicMock:
# ---------------------------------------------------------------------------
# E.1 NOT_SELF_VERIFIED
# E.1 self_verified is no longer a gate (audit P1-3/D-08)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_am_done_blocks_when_not_self_verified() -> None:
async def test_i_am_done_auto_runs_submit_verification_when_in_progress() -> None:
"""Strict i_am_done auto-runs submit_verification (in_progress→verifying)
so the dev doesn't need a separate verb. The previous NOT_SELF_VERIFIED
gate required submit_for_verification which wasn't on any manifest.
"""
agent_id = uuid4()
task_id = uuid4()
t = _ready_task(task_id, agent_id)
t.self_verified = False
t.status = "in_progress"
after_verify = MagicMock(
**{**t.__dict__, "self_verified": True, "status": "verifying"}
)
after_submit = MagicMock(**{**after_verify.__dict__, "status": "awaiting_qa"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.submit_verification.return_value = after_verify
task_svc.submit_qa.return_value = after_submit
task_svc.qa_agent_for_team.return_value = MagicMock(
id=uuid4(), skills=[{"id": "code_review"}]
)
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
work_svc = AsyncMock()
work_svc.files_changed.return_value = ["foo.py"]
deps = _make_deps(task=task_svc, journal=journal_svc, work_session=work_svc)
c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "done")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "NOT_SELF_VERIFIED" in body["missing"] or "self_verified" in body["missing"]
task_svc.submit_qa.assert_not_awaited()
assert body["error"] is None
task_svc.submit_verification.assert_awaited_once()
task_svc.submit_qa.assert_awaited_once()
# ---------------------------------------------------------------------------
@@ -188,6 +204,11 @@ async def test_i_am_done_proceeds_when_all_gates_pass() -> None:
agent_id = uuid4()
task_id = uuid4()
t = _ready_task(task_id, agent_id)
# Pre-verifying state (caller already ran submit_for_verification or
# task is already in `verifying`). i_am_done skips the auto-verify
# step and goes straight to submit_qa.
t.status = "verifying"
t.self_verified = True
after_submit = MagicMock(
**{**t.__dict__, "status": "awaiting_qa"},
)
@@ -209,61 +230,17 @@ async def test_i_am_done_proceeds_when_all_gates_pass() -> None:
assert body["error"] is None
assert body["status"] == "awaiting_qa"
task_svc.submit_qa.assert_awaited_once()
# Strict path must NOT call submit_verification, push, or create_pr —
# those are catch-up side effects which are now opt-in only.
# Already-verifying status: no auto-call to submit_verification.
task_svc.submit_verification.assert_not_awaited()
# ---------------------------------------------------------------------------
# E.6 i_am_done_with_catchup retains the smart-catch-up convenience.
# E.6 — Removed: i_am_done_with_catchup verb deleted (audit P2-5/D-16).
# Its functionality is now split between submit_for_qa (push + PR) and
# i_am_done (auto-run submit_verification then submit_qa).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_am_done_with_catchup_runs_full_chain() -> None:
agent_id = uuid4()
task_id = uuid4()
initial = _ready_task(task_id, agent_id)
initial.self_verified = False
initial.pr_number = None
after_verify = MagicMock(
**{**initial.__dict__, "self_verified": True, "status": "verifying"}
)
after_pr_refresh = MagicMock(
**{**after_verify.__dict__, "pr_number": 8, "pr_url": "https://x/pr/8"}
)
after_submit = MagicMock(**{**after_pr_refresh.__dict__, "status": "awaiting_qa"})
task_svc = AsyncMock()
task_svc.get.side_effect = [initial, after_pr_refresh]
task_svc.submit_verification.return_value = after_verify
task_svc.submit_qa.return_value = after_submit
task_svc.qa_agent_for_team.return_value = MagicMock(
id=uuid4(), skills=[{"id": "code_review"}]
)
work_svc = AsyncMock()
work_svc.has_unpushed_commits.return_value = True
work_svc.files_changed.return_value = ["foo.py"]
git_svc = AsyncMock()
git_svc.create_pr.return_value = {"pr_number": 8, "pr_url": "https://x/pr/8"}
journal_svc = AsyncMock()
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(
task=task_svc,
journal=journal_svc,
work_session=work_svc,
git=git_svc,
)
c = Choreographer(deps)
env = await c.i_am_done_with_catchup(agent_id, task_id, "all done")
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "awaiting_qa"
task_svc.submit_verification.assert_awaited_once()
git_svc.push_branch.assert_awaited_once()
git_svc.create_pr.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_done_blocks_unauthorized() -> None:
"""Existing not_authorized check still applies."""
-1
View File
@@ -24,7 +24,6 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
task.get_active_task_for_agent.return_value = None
# commit() now checks caller role; default to developer.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer")
@@ -20,7 +20,6 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
# commit() checks caller role server-side; default-created mocks
# need a default developer role so existing tests pass through.
# Caller-supplied mocks must set agent_for themselves.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer")
@@ -29,7 +29,6 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
task.get_active_task_for_agent.return_value = None
# commit() now checks caller role; default to developer.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer")
@@ -74,21 +74,6 @@ async def test_i_will_work_on_calls_heartbeat() -> None:
task_svc.heartbeat.assert_awaited_with(tid)
@pytest.mark.asyncio
async def test_i_have_committed_calls_heartbeat() -> None:
aid = uuid4()
tid = uuid4()
t = MagicMock(id=tid, status="in_progress", assigned_to=aid, plan="x")
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = t
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c.i_have_committed(aid, "did the thing")
task_svc.heartbeat.assert_awaited_with(tid)
@pytest.mark.asyncio
async def test_i_am_done_calls_heartbeat() -> None:
aid = uuid4()
+24
View File
@@ -2,16 +2,40 @@
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Same pattern as test_flow_server: do_server now refuses to start without
# a manifest (audit P0-5 / D-12). The test fixture writes a stub manifest
# with the full do-tool superset; production manifests are role-scoped.
_DO_TEST_MANIFEST = {
"agent_id": "00000000-0000-0000-0000-000000000001",
"role": "developer",
"team": "backend",
"workspace_path": "/tmp/test",
"flow_tools": [],
"do_tools": ["commit", "note", "say", "dm", "notify", "evidence"],
"read_tools": [],
"write_tools": [],
"bash_allowed": True,
"subagent_allowed": False,
"subagent_model": None,
"env": {},
}
@pytest.fixture
def do_module(monkeypatch): # type: ignore[no-untyped-def]
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
manifest_path = Path(tempfile.mkdtemp()) / "tool-manifest.json"
manifest_path.write_text(json.dumps(_DO_TEST_MANIFEST))
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
import importlib
import roboco.mcp.do_server as srv
+109 -98
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import importlib
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
@@ -10,14 +11,60 @@ import pytest
if TYPE_CHECKING:
import types
from pathlib import Path
_FULL_MANIFEST = {
"agent_id": "00000000-0000-0000-0000-000000000001",
"role": "developer",
"team": "backend",
"workspace_path": "/tmp/test",
# Test fixture provides every flow verb so per-verb URL/path tests work
# against a single fixture. Production manifests are role-scoped.
"flow_tools": [
"give_me_work",
"i_will_work_on",
"submit_for_qa",
"i_am_done",
"i_am_blocked",
"unclaim",
"resume",
"i_am_idle",
"claim_review",
"pass",
"fail",
"claim_doc_task",
"i_documented",
"triage",
"triage_all",
"unblock",
"complete",
"escalate_up",
"i_will_plan",
"delegate",
"submit_up",
"escalate_to_ceo",
],
"do_tools": ["commit", "note", "say", "dm", "evidence"],
"read_tools": ["Read", "Glob", "Grep"],
"write_tools": ["Edit", "Write"],
"bash_allowed": True,
"subagent_allowed": False,
"subagent_model": None,
"env": {},
}
@pytest.fixture()
def flow_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
"""Import the flow_server module with controlled env vars."""
def flow_module(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> types.ModuleType:
"""Import the flow_server module with controlled env vars + manifest."""
manifest_path = tmp_path / "tool-manifest.json"
manifest_path.write_text(json.dumps(_FULL_MANIFEST))
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
import roboco.mcp.flow_server as srv
@@ -36,6 +83,33 @@ def _make_fake_client(return_value: dict[str, Any]) -> MagicMock:
return fake_client
def _reload_for_role(
monkeypatch: pytest.MonkeyPatch, role: str, agent_id: str
) -> types.ModuleType:
"""Set env + write manifest for the given role; reload flow_server.
The manifest provides the full verb superset so role-specific tests
aren't blocked by the manifest filter; the role-scoped URL routing
is what's under test in these per-role cases.
"""
import tempfile
from pathlib import Path
manifest_path = Path(tempfile.mkdtemp()) / "tool-manifest.json"
payload = {**_FULL_MANIFEST, "role": role, "agent_id": agent_id}
manifest_path.write_text(json.dumps(payload))
monkeypatch.setenv("ROBOCO_AGENT_ID", agent_id)
monkeypatch.setenv("ROBOCO_AGENT_ROLE", role)
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
import roboco.mcp.flow_server as srv
importlib.reload(srv)
return srv
def test_role_path_uses_agent_role(flow_module: types.ModuleType) -> None:
expected = "/api/v2/flow/developer/give_me_work"
assert flow_module._role_path("give_me_work") == expected
@@ -80,17 +154,6 @@ def test_i_will_work_on_plan_defaults_to_none(flow_module: types.ModuleType) ->
assert kwargs["json"] == {"task_id": "task-uuid", "plan": None}
def test_i_have_committed_sends_message(flow_module: types.ModuleType) -> None:
fake_client = _make_fake_client({"status": "recorded"})
with patch("httpx.Client", return_value=fake_client):
result = flow_module.i_have_committed("fix: typo in handler")
assert result == {"status": "recorded"}
_, kwargs = fake_client.post.call_args
assert kwargs["json"] == {"message": "fix: typo in handler"}
def test_i_am_done_sends_task_id_and_notes(flow_module: types.ModuleType) -> None:
fake_client = _make_fake_client({"status": "awaiting_qa"})
@@ -138,13 +201,7 @@ def test_i_am_idle_posts_empty_body(flow_module: types.ModuleType) -> None:
def test_claim_review_posts_to_qa_path(monkeypatch: pytest.MonkeyPatch) -> None:
"""When AGENT_ROLE=qa, claim_review forwards to /api/v2/flow/qa/claim_review."""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000002")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "qa")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(monkeypatch, "qa", "00000000-0000-0000-0000-000000000002")
fake_client = _make_fake_client({"status": "claimed", "evidence": {}})
@@ -158,13 +215,7 @@ def test_claim_review_posts_to_qa_path(monkeypatch: pytest.MonkeyPatch) -> None:
def test_pass_review_passes_notes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000002")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "qa")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(monkeypatch, "qa", "00000000-0000-0000-0000-000000000002")
fake_client = _make_fake_client({"status": "awaiting_documentation"})
@@ -178,13 +229,7 @@ def test_pass_review_passes_notes(monkeypatch: pytest.MonkeyPatch) -> None:
def test_fail_review_passes_issues_list(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000002")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "qa")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(monkeypatch, "qa", "00000000-0000-0000-0000-000000000002")
fake_client = _make_fake_client({"status": "needs_revision"})
@@ -201,13 +246,9 @@ def test_claim_doc_task_posts_to_documenter_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When AGENT_ROLE=documenter, claim_doc_task forwards to documenter flow."""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000003")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "documenter")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "documenter", "00000000-0000-0000-0000-000000000003"
)
fake_client = _make_fake_client({"status": "claimed"})
@@ -221,13 +262,9 @@ def test_claim_doc_task_posts_to_documenter_path(
def test_i_documented_passes_notes_and_files(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000003")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "documenter")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "documenter", "00000000-0000-0000-0000-000000000003"
)
fake_client = _make_fake_client({"status": "awaiting_pm_review"})
@@ -245,13 +282,9 @@ def test_i_documented_passes_notes_and_files(monkeypatch: pytest.MonkeyPatch) ->
def test_triage_uses_role_path(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
)
fake_client = _make_fake_client({"status": "blocked"})
@@ -265,13 +298,9 @@ def test_triage_uses_role_path(monkeypatch: pytest.MonkeyPatch) -> None:
def test_triage_all_uses_role_path(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000005")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "main_pm")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "main_pm", "00000000-0000-0000-0000-000000000005"
)
fake_client = _make_fake_client({"status": "idle"})
@@ -285,13 +314,9 @@ def test_triage_all_uses_role_path(monkeypatch: pytest.MonkeyPatch) -> None:
def test_unblock_with_restore_true(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
)
fake_client = _make_fake_client({"status": "in_progress"})
@@ -305,13 +330,9 @@ def test_unblock_with_restore_true(monkeypatch: pytest.MonkeyPatch) -> None:
def test_unblock_with_restore_false(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
)
fake_client = _make_fake_client({"status": "in_progress"})
@@ -324,13 +345,9 @@ def test_unblock_with_restore_false(monkeypatch: pytest.MonkeyPatch) -> None:
def test_complete_passes_notes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
)
fake_client = _make_fake_client({"status": "completed"})
@@ -344,13 +361,9 @@ def test_complete_passes_notes(monkeypatch: pytest.MonkeyPatch) -> None:
def test_escalate_up_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
)
fake_client = _make_fake_client({"status": "blocked"})
@@ -368,13 +381,9 @@ def test_escalate_up_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
def test_escalate_to_ceo_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
"""Board / Main PM verb forwards to /api/v2/flow/<role>/escalate_to_ceo."""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000005")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "product_owner")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
srv = _reload_for_role(
monkeypatch, "product_owner", "00000000-0000-0000-0000-000000000005"
)
fake_client = _make_fake_client({"status": "awaiting_ceo_approval"})
@@ -383,7 +392,9 @@ def test_escalate_to_ceo_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
assert result["status"] == "awaiting_ceo_approval"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/product_owner/escalate_to_ceo" in args[0]
# Board route serves PO + Head Marketing under one prefix; the slug
# map in flow_server translates product_owner → board.
assert "/api/v2/flow/board/escalate_to_ceo" in args[0]
assert kwargs["json"] == {
"task_id": "task-uuid",
"reason": "strategic decision needed",
+1 -1
View File
@@ -42,7 +42,7 @@ def test_blocks_internal_curl_to_orchestrator() -> None:
def test_blocks_internal_curl_to_localhost() -> None:
assert _run("curl http://localhost:8000/api/v2/flow/dev/i_am_done") == _DENIED
assert _run("curl http://localhost:8000/api/v2/flow/developer/i_am_done") == _DENIED
def test_blocks_internal_curl_to_127() -> None:
+13 -6
View File
@@ -442,27 +442,34 @@ async def test_doc_claim_sets_assignment_on_awaiting_documentation() -> None:
@pytest.mark.asyncio
async def test_qa_pass_delegates_to_pass_qa() -> None:
svc = TaskService(MagicMock())
pass_qa_mock = AsyncMock(return_value=MagicMock())
_bind(svc, "pass_qa", pass_qa_mock)
qa_id = uuid4()
task_id = uuid4()
await svc.qa_pass(uuid4(), task_id, "looks good")
task = _build_task(id=task_id, claimed_by=qa_id)
svc = TaskService(MagicMock(flush=AsyncMock()))
pass_qa_mock = AsyncMock(return_value=MagicMock())
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "pass_qa", pass_qa_mock)
await svc.qa_pass(qa_id, task_id, "looks good")
pass_qa_mock.assert_awaited_once_with(task_id, notes="looks good", agent_role="qa")
# active_claimant_id cleared so the documenter can claim cleanly.
assert task.active_claimant_id is None
@pytest.mark.asyncio
async def test_qa_fail_appends_issues_to_dev_notes() -> None:
task = _build_task(dev_notes=None)
qa_id = uuid4()
task = _build_task(dev_notes=None, claimed_by=qa_id)
svc = TaskService(MagicMock(flush=AsyncMock()))
fail_qa_mock = AsyncMock(return_value=task)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "fail_qa", fail_qa_mock)
issues = ["missing test", "no docstring"]
await svc.qa_fail(uuid4(), task.id, "blocking", issues)
await svc.qa_fail(qa_id, task.id, "blocking", issues)
assert task.dev_notes is not None
assert "missing test" in task.dev_notes
assert "no docstring" in task.dev_notes
fail_qa_mock.assert_awaited_once_with(task.id, notes="blocking", agent_role="qa")
assert task.active_claimant_id is None
# ---------------------------------------------------------------------------
Generated
+10 -10
View File
@@ -1624,14 +1624,14 @@ wheels = [
[[package]]
name = "jedi"
version = "0.19.2"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "parso" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" }
sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
{ url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
]
[[package]]
@@ -3051,11 +3051,11 @@ wheels = [
[[package]]
name = "parso"
version = "0.8.6"
version = "0.8.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" }
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
]
[[package]]
@@ -3113,7 +3113,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "ptyprocess" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@@ -5735,11 +5735,11 @@ wheels = [
[[package]]
name = "wcwidth"
version = "0.6.0"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
]
[[package]]
-3
View File
@@ -1,3 +0,0 @@
# Vulture whitelist - min_confidence=100 in pyproject.toml.
# Add entries here only if vulture reports 100% confidence unused code that
# is actually used dynamically.