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