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 | | 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` | | qa | `claim_review`, `pass`, `fail` |
| documenter | `claim_doc_task`, `i_documented` | | documenter | `claim_doc_task`, `i_documented` |
| cell_pm | `triage`, `unblock`, `complete`, `escalate_up` | | 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. - **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. - **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 ## 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 ## 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`. - ❌ 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 will reject with `PARENT_NOT_CLAIMED` because the parent must be in `in_progress` and claimed by you. - ❌ 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. - ❌ 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 `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. - ❌ 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`. - ❌ 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. | | `give_me_work()` | Returns your highest-priority task or `idle`. | None. |
| `i_will_work_on(task_id, plan=None)` | Claims a `pending`/`needs_revision` task; resumes a `claimed`/`in_progress` task you own. Auto-creates branch on first claim. | Task assigned to you (or unassigned and matches your role/team); for `claimed` resumption, plan and branch must exist. | | `i_will_work_on(task_id, plan=None)` | Claims a `pending`/`needs_revision` task; resumes a `claimed`/`in_progress` task you own. Auto-creates branch on first claim. | Task assigned to you (or unassigned and matches your role/team); for `claimed` resumption, plan and branch must exist. |
| `commit(message)` | Auto-prefixes `[task-id]`; records a progress entry. | Task in `in_progress`; on your branch. | | `commit(message)` | Makes the git commit, auto-prefixes `[task-id]`, records a progress entry. This is the ONLY way to commit — the gateway covers the actual git operation. | Task in `in_progress`; on your branch. |
| `i_have_committed(message)` | Quick alias for `commit()`. | Same as `commit`. |
| `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. | | `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. | | `i_am_blocked(reason)` | Records the blocker, escalates to your PM, idles you. | Task is yours and active. |
| `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. | | `unclaim(task_id)` | Release this claim back to pending. Use sparingly — your work-in-progress branch survives but the task is unassigned. | Task assigned to you and in claimed/in_progress. |
| `resume(task_id)` | Resume a paused task. Transitions paused → in_progress. | Task assigned to you and in paused state. | | `resume(task_id)` | Resume a paused task. Transitions paused → in_progress. | Task assigned to you and in paused state. |
@@ -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. 5. `commit(message)` after each meaningful change. Repeat 4-5 until the criteria are met.
6. `note(scope='reflect', text="<what you did + why>")` before submitting. 6. `note(scope='reflect', text="<what you did + why>")` before submitting.
7. `submit_for_qa(task_id="<your-task>")` -> pushes your branch and opens the PR up to your cell PM's branch. The response includes the PR number. 7. `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`. 9. After `i_am_done` succeeds you are finished with this task. `i_am_idle()`. Documenter writes docs; PM merges. You will only be respawned on `needs_revision`.
## Anti-patterns ## Anti-patterns
- ❌ Calling `i_am_done` without commits / open PR / 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. - ❌ Editing files outside your assigned task's branch. Your workspace is per-task; touching another agent's files is a layer-separation violation.
- ❌ Trying to merge your own PR. Merging is a PM verb — you have no merge tool. If you call `Bash gh pr merge`, the orchestrator denies it. - ❌ Trying to merge your own PR. Merging is a PM verb — you have no merge tool. If you call `Bash gh pr merge`, the orchestrator denies it.
- ❌ Running `Bash git commit` or `Bash git push`. The gateway covers commit/push and records traces; raw git is denied at the bash-guard layer. - ❌ Running `Bash git commit` or `Bash git push`. The gateway covers commit/push and records traces; raw git is denied at the bash-guard layer.
- ❌ Spawning subagents to do your task for you. Subagents are for parallel research (read multiple files at once), not for executing your work. - ❌ 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. - ❌ 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 ## 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. - ❌ 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. - ❌ 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. - ❌ 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. - ❌ 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. Server-side gate rejects with `NO_DOC_FILES` / `DOC_NOTES_REQUIRED`. - ❌ 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. - ❌ Treating journal entries as documentation. Journals are private reflection; documentation is the artifact that ships in the PR.
## When the gateway returns an error ## 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 ## 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. - ❌ 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`. - ❌ 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 rejects with `PARENT_NOT_CLAIMED`. - ❌ 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. - ❌ 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 `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`. - ❌ 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`. - ❌ 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. 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. 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>")`. 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 ## 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. - ❌ 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. - ❌ 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. - ❌ 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. - ❌ 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. Server-side gate rejects with `QA_NOTES_REQUIRED`. - ❌ 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. - ❌ 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 ## When the gateway returns an error
+5
View File
@@ -248,6 +248,11 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings - ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Per-agent SessionStart briefings (pre-rendered task context) # Per-agent SessionStart briefings (pre-rendered task context)
- ${ROBOCO_DATA_DIR:-./data}/briefings:/app/briefings - ${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 # Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces - ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Persistent logs — survive `docker compose down/up`. Orchestrator and # 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) 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( def compose_prompt(
role: "AgentRole", role: "AgentRole",
team: "Team | None", team: "Team | None",
@@ -103,8 +117,9 @@ def compose_prompt(
Combines: Combines:
1. base.md - Universal rules (all agents) 1. base.md - Universal rules (all agents)
2. roles/{role}.md - Role-specific behavior 2. roles/{role}.md - Role-specific behavior
3. teams/{team}.md - Team context (if team is set) 3. _generated/{role}.md - Autogenerated verb-signature table from schemas
4. identities/{agent_slug}.md - Agent identity 4. teams/{team}.md - Team context (if team is set)
5. identities/{agent_slug}.md - Agent identity
Args: Args:
role: Agent's role (developer, qa, pm, documenter, board) role: Agent's role (developer, qa, pm, documenter, board)
@@ -121,6 +136,7 @@ def compose_prompt(
for layer in ( for layer in (
_load_layer(prompts_path / "base.md"), _load_layer(prompts_path / "base.md"),
_role_layer(prompts_path, role), _role_layer(prompts_path, role),
_autogen_verbs_layer(prompts_path, role),
_team_layer(prompts_path, team), _team_layer(prompts_path, team),
_load_layer(prompts_path / "identities" / f"{agent_slug}.md"), _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, status_code=status.HTTP_400_BAD_REQUEST,
detail=( detail=(
"NO_PROGRESS: Cannot submit for QA without any " "NO_PROGRESS: Cannot submit for QA without any "
"progress updates. Add progress entries via the gateway " "progress updates. Make at least one commit() during "
"(i_have_committed auto-records) at least once during " "execution — commit() auto-records a progress entry."
"execution before submitting."
), ),
) )
+2 -14
View File
@@ -12,7 +12,6 @@ from roboco.api.schemas.v2.flow import (
IAmBlockedRequest, IAmBlockedRequest,
IAmDoneRequest, IAmDoneRequest,
IAmIdleRequest, IAmIdleRequest,
IHaveCommittedRequest,
IWillWorkOnRequest, IWillWorkOnRequest,
ResumeRequest, ResumeRequest,
SubmitForQaRequest, SubmitForQaRequest,
@@ -21,8 +20,8 @@ from roboco.api.schemas.v2.flow import (
from roboco.services.gateway.choreographer import Choreographer from roboco.services.gateway.choreographer import Choreographer
router = APIRouter( router = APIRouter(
prefix="/api/v2/flow/dev", prefix="/api/v2/flow/developer",
tags=["v2-flow-dev"], tags=["v2-flow-developer"],
dependencies=[require_dev], dependencies=[require_dev],
) )
@@ -53,17 +52,6 @@ async def i_will_work_on(
return envelope_to_response(env, request) 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") @router.post("/submit_for_qa")
async def submit_for_qa( async def submit_for_qa(
request: Request, request: Request,
-4
View File
@@ -14,10 +14,6 @@ class IWillWorkOnRequest(BaseModel):
plan: str | None = None plan: str | None = None
class IHaveCommittedRequest(BaseModel):
message: str = Field(..., min_length=1)
class SubmitForQaRequest(BaseModel): class SubmitForQaRequest(BaseModel):
task_id: UUID task_id: UUID
+5 -3
View File
@@ -299,10 +299,12 @@ class Settings(BaseSettings):
description="Enable Agent Gateway feature (Phase 0 flag)", description="Enable Agent Gateway feature (Phase 0 flag)",
) )
manifest_host_dir: str = Field( manifest_host_dir: str = Field(
default="/var/lib/roboco/manifests", default="/app/manifests",
description=( description=(
"Host-side directory where per-agent tool manifests are written before " "Orchestrator-side directory where per-agent tool manifests are "
"being bind-mounted into developer containers as /app/tool-manifest.json" "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( public_base_url: str = Field(
+7 -2
View File
@@ -41,11 +41,16 @@ VALID_TRANSITIONS: dict[str, list[str]] = {
"pending": ["claimed", "cancelled"], "pending": ["claimed", "cancelled"],
# Claimed - can start, unclaim, or cancel # Claimed - can start, unclaim, or cancel
"claimed": ["in_progress", "pending", "cancelled"], "claimed": ["in_progress", "pending", "cancelled"],
# In progress - can block, pause, verify, submit for PM review, complete, or cancel # In progress - can block, pause, verify, submit for PM review, complete,
# QA direct assignment: QA can also pass/fail when assigned directly # 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": [ "in_progress": [
"blocked", "blocked",
"paused", "paused",
"pending", # voluntary unclaim or reaper sweep
"verifying", "verifying",
"awaiting_pm_review", "awaiting_pm_review",
"awaiting_documentation", # QA pass when assigned directly "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]: 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: with httpx.Client(timeout=_TIMEOUT) as client:
response = client.post( response = client.post(
f"{ORCHESTRATOR_URL}{path}", f"{ORCHESTRATOR_URL}{path}",
headers=_build_headers(), headers=_build_headers(),
json=body, json=body,
) )
response.raise_for_status() try:
result: dict[str, Any] = response.json() payload: dict[str, Any] = response.json()
return result 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]: 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]: 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. Returns the list of tool names actually registered.
""" """
allowed = _load_manifest_do_tools() allowed = _load_manifest_do_tools()
if allowed is None: if allowed is None:
log.warning( manifest_path = os.environ.get(
"do_server: manifest unavailable; registering all do tools", "ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"
role=AGENT_ROLE,
) )
names = list(_TOOLS) msg = (
else: 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] unknown = [verb for verb in allowed if verb not in _TOOLS]
if unknown: if unknown:
log.warning( 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]: 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: with httpx.Client(timeout=_TIMEOUT) as client:
response = client.post( response = client.post(
f"{ORCHESTRATOR_URL}{path}", f"{ORCHESTRATOR_URL}{path}",
headers=_build_headers(), headers=_build_headers(),
json=body, json=body,
) )
response.raise_for_status() try:
result: dict[str, Any] = response.json() payload: dict[str, Any] = response.json()
return result 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: def _role_path(verb: str) -> str:
"""Build the role-scoped /api/v2/flow/<role>/<verb> path.""" """Build the role-scoped /api/v2/flow/<route>/<verb> path."""
return f"/api/v2/flow/{AGENT_ROLE}/{verb}" return f"/api/v2/flow/{_ROUTE_PREFIX}/{verb}"
# ---------- Dev verbs ---------- # ---------- 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}) 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]: 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.""" """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}) return _post(_role_path("submit_for_qa"), {"task_id": task_id})
@@ -235,7 +264,6 @@ _TOOLS: dict[str, Any] = {
# dev # dev
"give_me_work": give_me_work, "give_me_work": give_me_work,
"i_will_work_on": i_will_work_on, "i_will_work_on": i_will_work_on,
"i_have_committed": i_have_committed,
"submit_for_qa": submit_for_qa, "submit_for_qa": submit_for_qa,
"i_am_done": i_am_done, "i_am_done": i_am_done,
"i_am_blocked": i_am_blocked, "i_am_blocked": i_am_blocked,
@@ -294,18 +322,30 @@ def _load_manifest_flow_tools() -> list[str] | None:
def _register_tools() -> list[str]: 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. Returns the list of verb names actually registered.
""" """
allowed = _load_manifest_flow_tools() allowed = _load_manifest_flow_tools()
if allowed is None: if allowed is None:
log.warning( manifest_path = os.environ.get(
"flow_server: manifest unavailable; registering all flow verbs", "ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"
role=AGENT_ROLE,
) )
names = list(_TOOLS) msg = (
else: 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] unknown = [verb for verb in allowed if verb not in _TOOLS]
if unknown: if unknown:
log.warning( 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) # Two paths in play:
host_path = host_dir / f"{agent_id}.json" # - orchestrator-internal: where the file is written inside the
write_manifest(manifest, host_path) # orchestrator container (settings.manifest_host_dir). The compose
return host_path # 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. # agents that were WAITING_LONG at shutdown can still be resolved.
await self.restore_waiting_records() 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 # Note: Per-agent settings are now generated at spawn time
# via _generate_agent_settings() - no shared settings needed # via _generate_agent_settings() - no shared settings needed
@@ -1451,6 +1466,15 @@ class AgentOrchestrator:
f"{hosts['claude']}:/home/agent/.claude", 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") settings_host = hosts.get("settings")
if settings_host: if settings_host:
cmd.extend(["-v", f"{settings_host}:/home/agent/.claude/settings.json:ro"]) 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: except Exception as e:
logger.error("Dispatcher loop error", error=str(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: async def _reap_stale_claims(self) -> None:
"""Release claimed/in_progress tasks whose holder hasn't heart-beat in TTL. """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) owner_uuid = self._resolve_dev_owner_uuid(task)
agent_slug = self._resolve_agent_slug(owner_uuid) if owner_uuid else None 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 ( if agent_slug and status in (
"needs_revision", "needs_revision",
"in_progress", "in_progress",
@@ -4306,6 +4388,27 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
if agent_slug: if agent_slug:
await self._spawn_pending_dev(client, task, 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: 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. """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 Continue development. Required gates before i_am_done() will succeed
(enforced server-side `remediate` tells you what's missing): (enforced server-side `remediate` tells you what's missing):
1. commit("<type(scope): subject, >=20 chars>") 1. commit("<type(scope): subject, >=20 chars>")
stages tracked changes; auto-prefixes task ID. Repeat per chunk. makes the git commit, auto-prefixes task ID, records progress.
2. i_have_committed("<progress note>") Repeat per meaningful chunk.
record at least one progress entry. 2. note(scope='decision'|'learning'|'reflect', task_id="...", text=...)
3. note(scope='decision'|'learning'|'reflect', task_id="...", text=...)
as you make trade-offs. as you make trade-offs.
When acceptance criteria are met, call When acceptance criteria are met, call
i_am_done(task_id="...", notes="<self-verification summary>"): submit_for_qa(task_id="...") to push your branch and open the PR,
this chains submit_verification + push + create_pr + submit_qa in one verb. then i_am_done(task_id="...", notes="<self-verification summary>")
to submit for QA review.
If you hit something you can't unblock yourself: If you hit something you can't unblock yourself:
i_am_blocked(task_id="...", 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 import structlog
from roboco.config import settings
from roboco.services.gateway.claim_guards import ( from roboco.services.gateway.claim_guards import (
already_active_guard, already_active_guard,
paused_tasks_guard, paused_tasks_guard,
@@ -147,18 +146,24 @@ class Choreographer:
failures must NEVER block the verb (the agent's response is the failures must NEVER block the verb (the agent's response is the
contract; the audit row is observability-only). contract; the audit row is observability-only).
Also stashes ``correlation_id`` from the structlog contextvars Stashes ``correlation_id`` from the structlog contextvars (bound
(bound by ``CorrelationIdMiddleware`` for the inbound request) by ``CorrelationIdMiddleware`` for the inbound request) and a
into the audit row's ``details`` JSONB so post-mortem joins per-attempt id into the audit row's ``details`` JSONB. The
across logs and audit trail are possible. 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: if env.error is None:
return env return env
from uuid import uuid4 as _uuid4
details: dict[str, Any] = { details: dict[str, Any] = {
"verb": verb, "verb": verb,
"reason": env.error, "reason": env.error,
"message": env.message, "message": env.message,
"missing": env.missing or [], "missing": env.missing or [],
"attempt_id": str(_uuid4()),
} }
cid = structlog.contextvars.get_contextvars().get("correlation_id") cid = structlog.contextvars.get_contextvars().get("correlation_id")
if cid is not None: if cid is not None:
@@ -292,6 +297,32 @@ class Choreographer:
# Format: "<id> (<status>)" # Format: "<id> (<status>)"
return ", ".join(f"{s.id} ({s.status})" for s in non_terminal) 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( async def _i_will_work_on_pending(
self, self,
agent_id: UUID, agent_id: UUID,
@@ -308,7 +339,21 @@ class Choreographer:
if guard := await self._run_claim_guards(agent_id=agent_id, task=t): if guard := await self._run_claim_guards(agent_id=agent_id, task=t):
return self._with_briefing(guard, briefing), t return self._with_briefing(guard, briefing), t
# claim() transitions pending → claimed; idempotent for same assignee. # 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) 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: if t is None:
return Envelope.invalid_state( return Envelope.invalid_state(
message="claim failed", message="claim failed",
@@ -327,6 +372,64 @@ class Choreographer:
if plan: if plan:
t = await self.task.set_plan(task_id, plan) t = await self.task.set_plan(task_id, plan)
t = await self.task.start(task_id, agent_id) 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 return None, t
async def i_will_work_on( async def i_will_work_on(
@@ -344,37 +447,19 @@ class Choreographer:
status = str(t.status) status = str(t.status)
briefing = await self._briefing_for(agent_id, task_id) briefing = await self._briefing_for(agent_id, task_id)
rejection: Envelope | None = None
if status == "needs_revision": if status == "needs_revision":
# Resumption after QA rejection: agent already owned the task, rejection, t = await self._i_will_work_on_needs_revision(
# role-typed claim already passed at original claim time. agent_id, task_id, t, briefing
if t.assigned_to != agent_id: )
t = await self.task.claim(task_id, agent_id)
t = await self.task.start(task_id, agent_id)
elif status == "pending": elif status == "pending":
rejection, t = await self._i_will_work_on_pending( rejection, t = await self._i_will_work_on_pending(
agent_id, task_id, t, plan, briefing 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: elif status == "claimed" and t.assigned_to == agent_id:
# Resumption: skip sibling-sequence (already passed at claim). rejection, t = await self._i_will_work_on_claimed(
# Still enforce already_active/paused so concurrent claims fail. agent_id, task_id, t, briefing
guard = await self._run_claim_guards(
agent_id=agent_id, task=t, skip_sequence=True
) )
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: elif status == "in_progress" and t.assigned_to == agent_id:
# Idempotent re-entry: respawned dev re-calling i_will_work_on # Idempotent re-entry: respawned dev re-calling i_will_work_on
# on a task they already own in_progress. Skip start() (would # on a task they already own in_progress. Skip start() (would
@@ -383,12 +468,15 @@ class Choreographer:
# reaper activity. # reaper activity.
pass pass
else: else:
return await self._emit_rejection( rejection = Envelope.invalid_state(
Envelope.invalid_state(
message=f"task {task_id} is in {status}; cannot start work", message=f"task {task_id} is in {status}; cannot start work",
remediate="call give_me_work() to find an actionable task", remediate="call give_me_work() to find an actionable task",
context_briefing=briefing, context_briefing=briefing,
), )
if rejection is not None:
return await self._emit_rejection(
rejection,
agent_id=agent_id, agent_id=agent_id,
task_id=task_id, task_id=task_id,
verb="i_will_work_on", verb="i_will_work_on",
@@ -399,8 +487,8 @@ class Choreographer:
status=str(t.status), status=str(t.status),
task_id=str(task_id), task_id=str(task_id),
next=( next=(
"edit + commit; call i_have_committed when ready," "edit + commit(message) for each meaningful change,"
" or i_am_done when finished" " then submit_for_qa(task_id) and i_am_done(task_id)"
), ),
context_briefing=briefing, context_briefing=briefing,
) )
@@ -411,46 +499,6 @@ class Choreographer:
env.context_briefing = briefing env.context_briefing = briefing
return env 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: 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 — """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. 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: 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 Preconditions enforced by gates:
(NOT_SELF_VERIFIED, NO_COMMITS, NO_PR, NO_PROGRESS) before - tracing: progress entry, journal:reflect, acceptance criteria
transitioning verifying awaiting_qa. The strict gateway path - field-level: at least one commit, PR open
re-enforces those exactly: dev MUST have already committed, The dev must have called ``commit()`` (do_server) at least once and
pushed, opened a PR, reported progress, and self-verified before ``submit_for_qa(task_id)`` to push + open the PR. Calling i_am_done
i_am_done can submit. 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 The previous strict path required a separate ``submit_for_verification``
on the dev's behalf, see ``i_am_done_with_catchup``. 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) t = await self.task.get(task_id)
if t is None: if t is None:
@@ -564,13 +616,20 @@ class Choreographer:
rejection, agent_id=agent_id, task_id=task_id, verb="i_am_done" 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): if rejection := await self._check_submit_qa_field_gates(agent_id, task_id, t):
return await self._emit_rejection( return await self._emit_rejection(
rejection, agent_id=agent_id, task_id=task_id, verb="i_am_done" 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) submitted = await self.task.submit_qa(agent_id, task_id, notes)
if submitted is not None: if submitted is not None:
t = submitted t = submitted
@@ -578,50 +637,6 @@ class Choreographer:
await self._touch(task_id) await self._touch(task_id)
return await self._build_i_am_done_ok(agent_id, task_id, t) 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( async def _check_tracing_gates(
self, agent_id: UUID, task_id: UUID, t: Any self, agent_id: UUID, task_id: UUID, t: Any
) -> Envelope | None: ) -> Envelope | None:
@@ -651,13 +666,10 @@ class Choreographer:
""" """
missing: list[str] = [] missing: list[str] = []
hints: list[str] = [] hints: list[str] = []
if not t.self_verified: # NOTE: self_verified is no longer a precondition — i_am_done auto-runs
missing.append("NOT_SELF_VERIFIED") # the in_progress → verifying transition which sets it. The previous
hints.append( # NOT_SELF_VERIFIED gate required a separate submit_for_verification
"call commit(message=...) to add a self-verified commit first;" # verb that wasn't on any manifest (audit D-08).
" self_verified is set automatically when you commit on this"
" task's branch"
)
if not t.commits: if not t.commits:
missing.append("NO_COMMITS") missing.append("NO_COMMITS")
hints.append( hints.append(
@@ -667,8 +679,8 @@ class Choreographer:
if t.pr_number is None: if t.pr_number is None:
missing.append("NO_PR") missing.append("NO_PR")
hints.append( hints.append(
"no PR open — push your branch and open a PR before" "no PR open — call submit_for_qa(task_id) to push your"
" i_am_done (or call i_am_done_with_catchup to do it auto)" " branch and open the PR, then retry i_am_done"
) )
if not missing: if not missing:
return None return None
@@ -727,24 +739,6 @@ class Choreographer:
context_briefing=await self._briefing_for(agent_id, task_id), 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: 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. """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: def _resolve_skill(self, target_agent: Any, preference: list[str]) -> str:
"""Pick first skill in preference list that target_agent has. """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() have: set[str] = set()
for s in target_agent.skills or []: for s in raw or []:
if isinstance(s, dict): if isinstance(s, dict):
sid = s.get("id") sid = s.get("id")
if sid: if sid:
@@ -908,7 +910,7 @@ class Choreographer:
return Envelope.ok( return Envelope.ok(
status=str(after.status), status=str(after.status),
task_id=str(task_id), 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, context_briefing=briefing,
) )
@@ -1014,419 +1016,11 @@ class Choreographer:
paused_ids.append(str(t.id)) paused_ids.append(str(t.id))
return paused_ids return paused_ids
# --- Phase 2 (QA) verbs --- # --- Phase 2 (QA) verbs moved to ``qa.py`` (audit P2-2). ---
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 3 (documenter + PM) verbs --- # --- Phase 3 (documenter + PM) verbs ---
async def claim_doc_task(self, doc_agent_id: UUID, task_id: UUID) -> Envelope: # claim_doc_task + i_documented moved to ``doc.py`` (audit P2-2).
"""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),
)
async def _i_will_plan_preflight( async def _i_will_plan_preflight(
self, pm_agent_id: UUID, task_id: UUID, t: Any, plan: str self, pm_agent_id: UUID, task_id: UUID, t: Any, plan: str
@@ -1932,16 +1526,10 @@ class Choreographer:
remediate=hint_for_missing_journal_decision(), remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(pm_agent_id, task_id), context_briefing=await self._briefing_for(pm_agent_id, task_id),
) )
if not await self.task.all_subtasks_terminal(task_id): if env := await self._subtasks_not_terminal_envelope(
non_terminal = await self._non_terminal_subtask_ids(task_id) pm_agent_id, task_id, context_phrase="bubbling up"
return Envelope.tracing_gap( ):
missing=["subtasks not all terminal"], return env
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 not t.branch_name: if not t.branch_name:
return Envelope.invalid_state( return Envelope.invalid_state(
message="task has no branch; cannot open cell-level PR", message="task has no branch; cannot open cell-level PR",
@@ -2148,17 +1736,10 @@ class Choreographer:
remediate=hint_for_missing_journal_decision(), remediate=hint_for_missing_journal_decision(),
context_briefing=await self._briefing_for(pm_agent_id, task_id), context_briefing=await self._briefing_for(pm_agent_id, task_id),
) )
all_terminal = await self.task.all_subtasks_terminal(task_id) if env := await self._subtasks_not_terminal_envelope(
if not all_terminal: pm_agent_id, task_id, context_phrase="completing parent"
non_terminal = await self._non_terminal_subtask_ids(task_id) ):
return Envelope.tracing_gap( return env
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 t.pr_number is None: if t.pr_number is None:
return Envelope.invalid_state( return Envelope.invalid_state(
message="task has no PR; cannot merge", message="task has no PR; cannot merge",
@@ -2290,19 +1871,10 @@ class Choreographer:
main_pm_agent_id, root_task_id main_pm_agent_id, root_task_id
), ),
) )
all_terminal = await self.task.all_subtasks_terminal(root_task_id) if env := await self._subtasks_not_terminal_envelope(
if not all_terminal: main_pm_agent_id, root_task_id, context_phrase="escalating to CEO"
non_terminal = await self._non_terminal_subtask_ids(root_task_id) ):
return Envelope.tracing_gap( return env
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
),
)
return None return None
async def main_pm_complete( async def main_pm_complete(
@@ -2333,7 +1905,12 @@ class Choreographer:
if needs_pr: if needs_pr:
await self.git.create_pr(t.branch_name, parent="master", is_root_pr=True) 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 # 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 # ``assigned_to`` so no agent gets respawned to chase this task while
# it sits in awaiting_ceo_approval. # it sits in awaiting_ceo_approval.
@@ -2497,44 +2074,7 @@ class Choreographer:
context_briefing=await self._briefing_for(agent_id, task_id), context_briefing=await self._briefing_for(agent_id, task_id),
) )
async def board_triage(self, board_agent_id: UUID) -> Envelope: # board_triage + auditor_triage moved to ``board.py`` as the first
"""Phase 4: Board triage — next strategic root task awaiting PM review.""" # per-role mixin extraction (audit P2-2). The Choreographer class is
strategic = await self.task.list_strategic_for_board() # composed in ``__init__.py`` from BoardMixin + the rest of this
if strategic: # _impl. Methods now resolve via Python's MRO.
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),
)
@@ -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( return Envelope.ok(
status=str(t.status), status=str(t.status),
task_id=str(t.id), 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={}, context_briefing={},
) )
-1
View File
@@ -25,7 +25,6 @@ class RoleConfig:
_DEV_FLOW = ( _DEV_FLOW = (
"give_me_work", "give_me_work",
"i_will_work_on", "i_will_work_on",
"i_have_committed",
"submit_for_qa", "submit_for_qa",
"i_am_done", "i_am_done",
"i_am_blocked", "i_am_blocked",
+1 -3
View File
@@ -1784,9 +1784,7 @@ class GitService(BaseService):
) )
if workspace_agent_id is None and task.created_by: if workspace_agent_id is None and task.created_by:
workspace_agent_id = UUID(str(task.created_by)) workspace_agent_id = UUID(str(task.created_by))
workspace = await self.get_workspace( workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
project.slug, agent_id=workspace_agent_id
)
git_token = await self._get_project_token_or_raise(project.slug) git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace) owner, repo = self._parse_github_remote(workspace)
+99 -19
View File
@@ -942,10 +942,23 @@ class TaskService(BaseService):
) -> None: ) -> None:
""" """
Apply claim-side-effects: status transition, branch + work session + context. 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) # Set context for QA/Documenter claims (only if not already set)
self._set_original_developer_context(task, agent) 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) now = datetime.now(UTC)
task.assigned_to = cast("Any", agent_id) task.assigned_to = cast("Any", agent_id)
task.claimed_by = 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 # would touch the heartbeat — leading to an unclaim/reclaim
# tight loop hammering the orchestrator. # tight loop hammering the orchestrator.
task.last_heartbeat_at = now 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 agent_role = agent.role.value if agent and agent.role else None
if task.status in self._CLAIMABLE_STATUSES: if task.status in self._CLAIMABLE_STATUSES:
@@ -965,7 +983,18 @@ class TaskService(BaseService):
await self.session.flush() await self.session.flush()
if not task.branch_name: if not task.branch_name:
try:
await self._ensure_branch_for_task(task, agent_id) 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.session.refresh(task)
await self._create_work_session_if_needed(task, agent_id, agent_role) 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: async def unclaim_for_reaper(self, task_id: UUID) -> None:
"""Reaper-only unclaim: skip role checks, force the row back to pending. """Reaper-only unclaim: skip role checks, force the row back to pending.
Bypasses the normal claim guards because the holder is provably dead Routes through ``_validate_and_set_status`` (audit P2-4/D-20) so the
(no heartbeat past TTL) the operation is named with ``_for_reaper`` canonical state machine in ``enforcement/task_lifecycle.py`` records
so callers cannot accidentally use it as a regular unclaim path. the transition. Pre-fix this used raw UPDATE which bypassed
Clears ``assigned_to`` and ``last_heartbeat_at`` so the next claim VALID_TRANSITIONS making the lifecycle module's invariants diverge
starts fresh. 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( task = await self.get(task_id)
update(TaskTable) if task is None:
.where(TaskTable.id == task_id) return
.values( if task.status not in (TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS):
status=TaskStatus.PENDING, return
assigned_to=None, try:
last_heartbeat_at=None, 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() await self.session.flush()
async def unclaim_for_agent( async def unclaim_for_agent(
@@ -1889,6 +1927,7 @@ class TaskService(BaseService):
# _validate_and_set_status only updates `status`; clearing the # _validate_and_set_status only updates `status`; clearing the
# claim is the unclaim's specific side effect. # claim is the unclaim's specific side effect.
task.assigned_to = cast("Any", None) task.assigned_to = cast("Any", None)
task.active_claimant_id = cast("Any", None)
await self.session.flush() await self.session.flush()
return task return task
@@ -4628,9 +4667,21 @@ class TaskService(BaseService):
return None return None
if task.status != expected_status: if task.status != expected_status:
return None return None
now = datetime.now(UTC)
task.assigned_to = cast("Any", agent_id) task.assigned_to = cast("Any", agent_id)
task.claimed_by = 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() await self.session.flush()
return task return task
@@ -4649,10 +4700,26 @@ class TaskService(BaseService):
) -> TaskTable | None: ) -> TaskTable | None:
"""QA passes the task (gateway-flavored wrapper of pass_qa). """QA passes the task (gateway-flavored wrapper of pass_qa).
Records the QA agent as the one performing the transition. The The audit row is attributed to QA via task.claimed_by (set by
underlying pass_qa() clears assignment so a documenter can claim. 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") return await self.pass_qa(task_id, notes=notes, agent_role="qa")
async def qa_fail( async def qa_fail(
@@ -4666,14 +4733,27 @@ class TaskService(BaseService):
`notes` is the QA narrative (stored on `qa_notes`); `issues` is `notes` is the QA narrative (stored on `qa_notes`); `issues` is
appended to `dev_notes` as a checklist for the dev's revision. 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) task = await self.get(task_id)
if task is None: if task is None:
return 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: if issues:
issue_block = "[QA ISSUES]\n" + "\n".join(f"- {i}" for i in issues) issue_block = "[QA ISSUES]\n" + "\n".join(f"- {i}" for i in issues)
task.dev_notes = _append_capped(task.dev_notes, issue_block) 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() await self.session.flush()
return await self.fail_qa(task_id, notes=notes, agent_role="qa") 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 # 1. Dev: give_me_work -> i_will_work_on -> note -> i_am_done
# ------------------------------------------------------------------ # ------------------------------------------------------------------
r = client.post( r = client.post(
"/api/v2/flow/dev/give_me_work", "/api/v2/flow/developer/give_me_work",
headers={"X-Agent-ID": dev_id}, headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={}, json={},
) )
assert r.status_code == _HTTP_200 assert r.status_code == _HTTP_200
r = client.post( r = client.post(
"/api/v2/flow/dev/i_will_work_on", "/api/v2/flow/developer/i_will_work_on",
headers={"X-Agent-ID": dev_id}, headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={"task_id": str(uuid4()), "plan": "edit x then y"}, json={"task_id": str(uuid4()), "plan": "edit x then y"},
) )
assert r.json()["status"] == "in_progress" assert r.json()["status"] == "in_progress"
r = client.post( r = client.post(
"/api/v2/do/note", "/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"}, json={"text": "Reflected: did the work as planned.", "scope": "reflect"},
) )
assert r.json()["error"] is None assert r.json()["error"] is None
r = client.post( r = client.post(
"/api/v2/flow/dev/i_am_done", "/api/v2/flow/developer/i_am_done",
headers={"X-Agent-ID": dev_id}, headers={"X-Agent-ID": dev_id, "X-Agent-Role": "developer"},
json={"task_id": str(uuid4()), "notes": "all done"}, json={"task_id": str(uuid4()), "notes": "all done"},
) )
assert r.json()["status"] == "awaiting_qa" 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( r = client.post(
"/api/v2/flow/qa/claim_review", "/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())}, json={"task_id": str(uuid4())},
) )
assert r.json()["evidence"]["pr_number"] == _PR_NUMBER assert r.json()["evidence"]["pr_number"] == _PR_NUMBER
r = client.post( r = client.post(
"/api/v2/do/note", "/api/v2/do/note",
headers={"X-Agent-ID": qa_id}, headers={"X-Agent-ID": qa_id, "X-Agent-Role": "developer"},
json={ json={
"text": "Reviewed; all acceptance criteria addressed.", "text": "Reviewed; all acceptance criteria addressed.",
"scope": "learning", "scope": "learning",
@@ -263,7 +263,7 @@ async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
) )
r = client.post( r = client.post(
"/api/v2/flow/qa/pass", "/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}, json={"task_id": str(uuid4()), "notes": long_notes},
) )
assert r.json()["status"] == "awaiting_documentation" 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( r = client.post(
"/api/v2/flow/documenter/claim_doc_task", "/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())}, json={"task_id": str(uuid4())},
) )
assert r.status_code == _HTTP_200 assert r.status_code == _HTTP_200
r = client.post( r = client.post(
"/api/v2/flow/documenter/i_documented", "/api/v2/flow/documenter/i_documented",
headers={"X-Agent-ID": doc_id}, headers={"X-Agent-ID": doc_id, "X-Agent-Role": "documenter"},
json={ json={
"task_id": str(uuid4()), "task_id": str(uuid4()),
"notes": "Wrote backend/guides/feature-x.md covering usage and config.", "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( r = client.post(
"/api/v2/do/note", "/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"}, json={"text": "Decision: approve and merge.", "scope": "decision"},
) )
assert r.json()["error"] is None assert r.json()["error"] is None
r = client.post( r = client.post(
"/api/v2/flow/cell_pm/complete", "/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"}, json={"task_id": str(uuid4()), "notes": "Approved and merged"},
) )
assert r.json()["status"] == "completed" assert r.json()["status"] == "completed"
@@ -311,14 +311,14 @@ async def test_pending_to_awaiting_ceo_approval(stateful_app: tuple) -> None:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
r = client.post( r = client.post(
"/api/v2/do/note", "/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"}, json={"text": "Root task ready for prod.", "scope": "decision"},
) )
assert r.json()["error"] is None assert r.json()["error"] is None
r = client.post( r = client.post(
"/api/v2/flow/main_pm/complete", "/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"}, json={"task_id": str(uuid4()), "notes": "Ready for prod"},
) )
assert r.json()["status"] == "awaiting_ceo_approval" 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. Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked. No DB required Choreographer is mocked.
@@ -40,13 +40,13 @@ def _build_app(mock_choreographer: MagicMock) -> FastAPI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None: 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 = MagicMock()
mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle")) mock_chore.give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore)) client = TestClient(_build_app(mock_chore))
resp = client.post( resp = client.post(
"/api/v2/flow/dev/give_me_work", "/api/v2/flow/developer/give_me_work",
json={}, json={},
headers=_HEADERS, headers=_HEADERS,
) )
@@ -59,7 +59,7 @@ async def test_give_me_work_returns_envelope() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_i_will_work_on_dispatches_task_id() -> None: 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 = MagicMock()
mock_chore.i_will_work_on = AsyncMock( mock_chore.i_will_work_on = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID) 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)) client = TestClient(_build_app(mock_chore))
resp = client.post( 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"}, json={"task_id": _TASK_ID, "plan": "implement the feature"},
headers=_HEADERS, 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" 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 @pytest.mark.asyncio
async def test_i_am_done_dispatches_task_and_notes() -> None: 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 = MagicMock()
mock_chore.i_am_done = AsyncMock( mock_chore.i_am_done = AsyncMock(
return_value=_make_envelope(status="awaiting_qa", task_id=_TASK_ID) 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)) client = TestClient(_build_app(mock_chore))
resp = client.post( 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"}, json={"task_id": _TASK_ID, "notes": "all tests pass"},
headers=_HEADERS, headers=_HEADERS,
) )
@@ -125,7 +105,7 @@ async def test_i_am_done_dispatches_task_and_notes() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_i_am_blocked_dispatches_reason() -> None: 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 = MagicMock()
mock_chore.i_am_blocked = AsyncMock( mock_chore.i_am_blocked = AsyncMock(
return_value=_make_envelope(status="blocked", task_id=_TASK_ID) 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)) client = TestClient(_build_app(mock_chore))
resp = client.post( 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"}, json={"task_id": _TASK_ID, "reason": "waiting for design spec"},
headers=_HEADERS, headers=_HEADERS,
) )
@@ -145,13 +125,13 @@ async def test_i_am_blocked_dispatches_reason() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None: 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 = MagicMock()
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle")) mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore)) client = TestClient(_build_app(mock_chore))
resp = client.post( resp = client.post(
"/api/v2/flow/dev/i_am_idle", "/api/v2/flow/developer/i_am_idle",
json={}, json={},
headers=_HEADERS, headers=_HEADERS,
) )
@@ -162,27 +142,13 @@ async def test_i_am_idle_dispatches_agent_id() -> None:
mock_chore.i_am_idle.assert_awaited_once() 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: def test_i_am_blocked_rejects_empty_reason() -> None:
"""POST i_am_blocked rejects empty reason (min_length=1).""" """POST i_am_blocked rejects empty reason (min_length=1)."""
mock_chore = MagicMock() mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore)) client = TestClient(_build_app(mock_chore))
resp = client.post( resp = client.post(
"/api/v2/flow/dev/i_am_blocked", "/api/v2/flow/developer/i_am_blocked",
json={"task_id": _TASK_ID, "reason": ""}, json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS, headers=_HEADERS,
) )
+40 -2
View File
@@ -27,6 +27,9 @@ from __future__ import annotations
import asyncio import asyncio
import importlib import importlib
import json
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID from uuid import UUID
@@ -95,7 +98,7 @@ def test_route_stamps_request_correlation_id_onto_envelope() -> None:
app, _ = _build_app() app, _ = _build_app()
client = TestClient(app) client = TestClient(app)
r = client.post( r = client.post(
"/api/v2/flow/dev/give_me_work", "/api/v2/flow/developer/give_me_work",
json={}, json={},
headers={**_DEV_AGENT_HEADERS, "X-Correlation-ID": "trace-xyz"}, 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() app, _ = _build_app()
client = TestClient(app) client = TestClient(app)
r = client.post( r = client.post(
"/api/v2/flow/dev/give_me_work", "/api/v2/flow/developer/give_me_work",
json={}, json={},
headers=_DEV_AGENT_HEADERS, 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 import; we have to re-import after monkey-patching so the test sees
the patched values. The reload itself is the lazy import keeping the patched values. The reload itself is the lazy import keeping
importlib at the top-level keeps PLC0415 happy. 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_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer") monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") 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) module = importlib.import_module(dotted)
return importlib.reload(module) return importlib.reload(module)
+4 -4
View File
@@ -34,7 +34,7 @@ def _build_app() -> FastAPI:
def test_dev_route_rejects_qa_role() -> None: def test_dev_route_rejects_qa_role() -> None:
client = TestClient(_build_app()) client = TestClient(_build_app())
r = client.post( r = client.post(
"/api/v2/flow/dev/give_me_work", "/api/v2/flow/developer/give_me_work",
json={}, json={},
headers={ headers={
"X-Agent-ID": "00000000-0000-0000-0000-000000000001", "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: def test_dev_route_accepts_developer_role() -> None:
client = TestClient(_build_app()) client = TestClient(_build_app())
r = client.post( r = client.post(
"/api/v2/flow/dev/give_me_work", "/api/v2/flow/developer/give_me_work",
json={}, json={},
headers={ headers={
"X-Agent-ID": "00000000-0000-0000-0000-000000000001", "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: def test_dev_route_accepts_developer_role_case_insensitive() -> None:
client = TestClient(_build_app()) client = TestClient(_build_app())
r = client.post( r = client.post(
"/api/v2/flow/dev/give_me_work", "/api/v2/flow/developer/give_me_work",
json={}, json={},
headers={ headers={
"X-Agent-ID": "00000000-0000-0000-0000-000000000001", "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: def test_dev_route_rejects_missing_role_header() -> None:
client = TestClient(_build_app()) client = TestClient(_build_app())
r = client.post( r = client.post(
"/api/v2/flow/dev/give_me_work", "/api/v2/flow/developer/give_me_work",
json={}, json={},
headers={"X-Agent-ID": "00000000-0000-0000-0000-000000000001"}, 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" 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 # 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: async def test_audit_log_event_failure_does_not_propagate() -> None:
"""If log_event raises, the verb still returns the rejection envelope.""" """If log_event raises, the verb still returns the rejection envelope."""
aid = uuid4() aid = uuid4()
tid = uuid4()
task_svc = AsyncMock() 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 = AsyncMock()
audit_svc.log_event.side_effect = RuntimeError("audit DB down") audit_svc.log_event.side_effect = RuntimeError("audit DB down")
deps = _make_deps(task=task_svc, audit=audit_svc) deps = _make_deps(task=task_svc, audit=audit_svc)
c = Choreographer(deps) c = Choreographer(deps)
# Must not raise; the rejection envelope should still come back. # 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() 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"] assert "completed" in body["message"]
@pytest.mark.asyncio # test_i_am_done_with_catchup_full_chain removed (audit P2-5/D-16):
async def test_i_have_committed_records_progress() -> None: # i_am_done_with_catchup verb deleted. submit_for_qa now does push + PR
agent_id = uuid4() # explicitly; i_am_done auto-runs submit_verification + submit_qa.
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"
@pytest.mark.asyncio @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 @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() agent_id = uuid4()
task_id = uuid4() task_id = uuid4()
t = _ready_task(task_id, agent_id) t = _ready_task(task_id, agent_id)
t.self_verified = False 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 = AsyncMock()
task_svc.get.return_value = t 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 = AsyncMock()
journal_svc.has_reflect_for_task.return_value = True 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) c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "done") env = await c.i_am_done(agent_id, task_id, "done")
body = env.as_dict() body = env.as_dict()
assert body["error"] == "tracing_gap" assert body["error"] is None
assert "NOT_SELF_VERIFIED" in body["missing"] or "self_verified" in body["missing"] task_svc.submit_verification.assert_awaited_once()
task_svc.submit_qa.assert_not_awaited() 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() agent_id = uuid4()
task_id = uuid4() task_id = uuid4()
t = _ready_task(task_id, agent_id) 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( after_submit = MagicMock(
**{**t.__dict__, "status": "awaiting_qa"}, **{**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["error"] is None
assert body["status"] == "awaiting_qa" assert body["status"] == "awaiting_qa"
task_svc.submit_qa.assert_awaited_once() task_svc.submit_qa.assert_awaited_once()
# Strict path must NOT call submit_verification, push, or create_pr — # Already-verifying status: no auto-call to submit_verification.
# those are catch-up side effects which are now opt-in only.
task_svc.submit_verification.assert_not_awaited() 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 @pytest.mark.asyncio
async def test_i_am_done_blocks_unauthorized() -> None: async def test_i_am_done_blocks_unauthorized() -> None:
"""Existing not_authorized check still applies.""" """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 task.get_active_task_for_agent.return_value = None
# commit() now checks caller role; default to developer. # commit() now checks caller role; default to developer.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer") 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 # commit() checks caller role server-side; default-created mocks
# need a default developer role so existing tests pass through. # need a default developer role so existing tests pass through.
# Caller-supplied mocks must set agent_for themselves. # Caller-supplied mocks must set agent_for themselves.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer") 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 task.get_active_task_for_agent.return_value = None
# commit() now checks caller role; default to developer. # commit() now checks caller role; default to developer.
from unittest.mock import MagicMock
task.agent_for.return_value = MagicMock(role="developer") 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) 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 @pytest.mark.asyncio
async def test_i_am_done_calls_heartbeat() -> None: async def test_i_am_done_calls_heartbeat() -> None:
aid = uuid4() aid = uuid4()
+24
View File
@@ -2,16 +2,40 @@
from __future__ import annotations from __future__ import annotations
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest 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 @pytest.fixture
def do_module(monkeypatch): # type: ignore[no-untyped-def] def do_module(monkeypatch): # type: ignore[no-untyped-def]
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001") monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer") monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") 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 importlib
import roboco.mcp.do_server as srv import roboco.mcp.do_server as srv
+109 -98
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import json
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -10,14 +11,60 @@ import pytest
if TYPE_CHECKING: if TYPE_CHECKING:
import types 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() @pytest.fixture()
def flow_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType: def flow_module(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> types.ModuleType:
"""Import the flow_server module with controlled env vars.""" """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_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer") monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") 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 import roboco.mcp.flow_server as srv
@@ -36,6 +83,33 @@ def _make_fake_client(return_value: dict[str, Any]) -> MagicMock:
return fake_client 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: def test_role_path_uses_agent_role(flow_module: types.ModuleType) -> None:
expected = "/api/v2/flow/developer/give_me_work" expected = "/api/v2/flow/developer/give_me_work"
assert flow_module._role_path("give_me_work") == expected 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} 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: def test_i_am_done_sends_task_id_and_notes(flow_module: types.ModuleType) -> None:
fake_client = _make_fake_client({"status": "awaiting_qa"}) 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: 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.""" """When AGENT_ROLE=qa, claim_review forwards to /api/v2/flow/qa/claim_review."""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000002") srv = _reload_for_role(monkeypatch, "qa", "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)
fake_client = _make_fake_client({"status": "claimed", "evidence": {}}) 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: def test_pass_review_passes_notes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000002") srv = _reload_for_role(monkeypatch, "qa", "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)
fake_client = _make_fake_client({"status": "awaiting_documentation"}) 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: def test_fail_review_passes_issues_list(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000002") srv = _reload_for_role(monkeypatch, "qa", "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)
fake_client = _make_fake_client({"status": "needs_revision"}) fake_client = _make_fake_client({"status": "needs_revision"})
@@ -201,13 +246,9 @@ def test_claim_doc_task_posts_to_documenter_path(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""When AGENT_ROLE=documenter, claim_doc_task forwards to documenter flow.""" """When AGENT_ROLE=documenter, claim_doc_task forwards to documenter flow."""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000003") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "documenter") monkeypatch, "documenter", "00000000-0000-0000-0000-000000000003"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "claimed"}) 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: def test_i_documented_passes_notes_and_files(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000003") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "documenter") monkeypatch, "documenter", "00000000-0000-0000-0000-000000000003"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "awaiting_pm_review"}) 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: def test_triage_uses_role_path(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm") monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "blocked"}) 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: def test_triage_all_uses_role_path(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000005") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "main_pm") monkeypatch, "main_pm", "00000000-0000-0000-0000-000000000005"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "idle"}) 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: def test_unblock_with_restore_true(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm") monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "in_progress"}) 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: def test_unblock_with_restore_false(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm") monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "in_progress"}) 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: def test_complete_passes_notes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm") monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "completed"}) 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: def test_escalate_up_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm") monkeypatch, "cell_pm", "00000000-0000-0000-0000-000000000004"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "blocked"}) 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: def test_escalate_to_ceo_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
"""Board / Main PM verb forwards to /api/v2/flow/<role>/escalate_to_ceo.""" """Board / Main PM verb forwards to /api/v2/flow/<role>/escalate_to_ceo."""
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000005") srv = _reload_for_role(
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "product_owner") monkeypatch, "product_owner", "00000000-0000-0000-0000-000000000005"
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") )
import roboco.mcp.flow_server as srv
importlib.reload(srv)
fake_client = _make_fake_client({"status": "awaiting_ceo_approval"}) 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" assert result["status"] == "awaiting_ceo_approval"
args, kwargs = fake_client.post.call_args 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"] == { assert kwargs["json"] == {
"task_id": "task-uuid", "task_id": "task-uuid",
"reason": "strategic decision needed", "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: 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: 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 @pytest.mark.asyncio
async def test_qa_pass_delegates_to_pass_qa() -> None: async def test_qa_pass_delegates_to_pass_qa() -> None:
svc = TaskService(MagicMock()) qa_id = uuid4()
pass_qa_mock = AsyncMock(return_value=MagicMock())
_bind(svc, "pass_qa", pass_qa_mock)
task_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") 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 @pytest.mark.asyncio
async def test_qa_fail_appends_issues_to_dev_notes() -> None: 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())) svc = TaskService(MagicMock(flush=AsyncMock()))
fail_qa_mock = AsyncMock(return_value=task) fail_qa_mock = AsyncMock(return_value=task)
_bind(svc, "get", AsyncMock(return_value=task)) _bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "fail_qa", fail_qa_mock) _bind(svc, "fail_qa", fail_qa_mock)
issues = ["missing test", "no docstring"] 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 task.dev_notes is not None
assert "missing test" in task.dev_notes assert "missing test" in task.dev_notes
assert "no docstring" 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") 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]] [[package]]
name = "jedi" name = "jedi"
version = "0.19.2" version = "0.20.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "parso" }, { 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 = [ 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]] [[package]]
@@ -3051,11 +3051,11 @@ wheels = [
[[package]] [[package]]
name = "parso" name = "parso"
version = "0.8.6" version = "0.8.7"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -3113,7 +3113,7 @@ name = "pexpect"
version = "4.9.0" version = "4.9.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ 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" } 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 = [ wheels = [
@@ -5735,11 +5735,11 @@ wheels = [
[[package]] [[package]]
name = "wcwidth" name = "wcwidth"
version = "0.6.0" version = "0.7.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[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.