diff --git a/CLAUDE.md b/CLAUDE.md index 537cc548..a340d2fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/agents/prompts/_generated/auditor.md b/agents/prompts/_generated/auditor.md new file mode 100644 index 00000000..a0b5c851 --- /dev/null +++ b/agents/prompts/_generated/auditor.md @@ -0,0 +1,18 @@ + + + +## 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)` | diff --git a/agents/prompts/_generated/cell_pm.md b/agents/prompts/_generated/cell_pm.md new file mode 100644 index 00000000..e0105aaf --- /dev/null +++ b/agents/prompts/_generated/cell_pm.md @@ -0,0 +1,30 @@ + + + +## 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)` | diff --git a/agents/prompts/_generated/developer.md b/agents/prompts/_generated/developer.md new file mode 100644 index 00000000..2a77b2c3 --- /dev/null +++ b/agents/prompts/_generated/developer.md @@ -0,0 +1,27 @@ + + + +## 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)` | diff --git a/agents/prompts/_generated/documenter.md b/agents/prompts/_generated/documenter.md new file mode 100644 index 00000000..8ec87679 --- /dev/null +++ b/agents/prompts/_generated/documenter.md @@ -0,0 +1,25 @@ + + + +## 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)` | diff --git a/agents/prompts/_generated/head_marketing.md b/agents/prompts/_generated/head_marketing.md new file mode 100644 index 00000000..8c530863 --- /dev/null +++ b/agents/prompts/_generated/head_marketing.md @@ -0,0 +1,22 @@ + + + +## 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)` | diff --git a/agents/prompts/_generated/main_pm.md b/agents/prompts/_generated/main_pm.md new file mode 100644 index 00000000..349e6c0e --- /dev/null +++ b/agents/prompts/_generated/main_pm.md @@ -0,0 +1,30 @@ + + + +## 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)` | diff --git a/agents/prompts/_generated/product_owner.md b/agents/prompts/_generated/product_owner.md new file mode 100644 index 00000000..8c530863 --- /dev/null +++ b/agents/prompts/_generated/product_owner.md @@ -0,0 +1,22 @@ + + + +## 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)` | diff --git a/agents/prompts/_generated/qa.md b/agents/prompts/_generated/qa.md new file mode 100644 index 00000000..8306452d --- /dev/null +++ b/agents/prompts/_generated/qa.md @@ -0,0 +1,25 @@ + + + +## 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)` | diff --git a/agents/prompts/_generated/verbs.md b/agents/prompts/_generated/verbs.md new file mode 100644 index 00000000..1693668f --- /dev/null +++ b/agents/prompts/_generated/verbs.md @@ -0,0 +1,192 @@ + + + +# 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)` | + diff --git a/agents/prompts/base.md b/agents/prompts/base.md index 0e1016a5..93c7294d 100644 --- a/agents/prompts/base.md +++ b/agents/prompts/base.md @@ -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:` (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 diff --git a/agents/prompts/roles/cell_pm.md b/agents/prompts/roles/cell_pm.md index 615f84d8..239f79d2 100644 --- a/agents/prompts/roles/cell_pm.md +++ b/agents/prompts/roles/cell_pm.md @@ -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 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`. diff --git a/agents/prompts/roles/developer.md b/agents/prompts/roles/developer.md index 52392b69..0106116e 100644 --- a/agents/prompts/roles/developer.md +++ b/agents/prompts/roles/developer.md @@ -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="")` before submitting. 7. `submit_for_qa(task_id="")` -> pushes your branch and opens the PR up to your cell PM's branch. The response includes the PR number. -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="", notes="")` -> submit for QA against the PR you just opened. Auto-runs the in_progress→verifying→awaiting_qa transitions. Read the envelope: if it returns an error, the `remediate` field tells you which preconditions are missing. 9. After `i_am_done` succeeds you are finished with this task. `i_am_idle()`. Documenter writes docs; PM merges. You will only be respawned on `needs_revision`. ## Anti-patterns -- ❌ Calling `i_am_done` without commits / open PR / 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 " (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 diff --git a/agents/prompts/roles/documenter.md b/agents/prompts/roles/documenter.md index 579b1227..3f116db3 100644 --- a/agents/prompts/roles/documenter.md +++ b/agents/prompts/roles/documenter.md @@ -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 diff --git a/agents/prompts/roles/main_pm.md b/agents/prompts/roles/main_pm.md index b2af73d3..cd73f88d 100644 --- a/agents/prompts/roles/main_pm.md +++ b/agents/prompts/roles/main_pm.md @@ -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 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`. diff --git a/agents/prompts/roles/qa.md b/agents/prompts/roles/qa.md index 4b926124..2eb0e733 100644 --- a/agents/prompts/roles/qa.md +++ b/agents/prompts/roles/qa.md @@ -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="")`. -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=["", "", ...])` — 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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 2bea34df..3c4afc6b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/roboco/agents/factories/_base.py b/roboco/agents/factories/_base.py index 6411e506..1b92a33c 100644 --- a/roboco/agents/factories/_base.py +++ b/roboco/agents/factories/_base.py @@ -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/.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"), ): diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index f0a15f1e..2bae4417 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -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." ), ) diff --git a/roboco/api/routes/v2/flow_dev.py b/roboco/api/routes/v2/flow_dev.py index d69d1cfb..f36fa690 100644 --- a/roboco/api/routes/v2/flow_dev.py +++ b/roboco/api/routes/v2/flow_dev.py @@ -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, diff --git a/roboco/api/schemas/v2/flow.py b/roboco/api/schemas/v2/flow.py index 12645743..bd8ab91a 100644 --- a/roboco/api/schemas/v2/flow.py +++ b/roboco/api/schemas/v2/flow.py @@ -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 diff --git a/roboco/config.py b/roboco/config.py index aac77532..6684e4f5 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -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( diff --git a/roboco/enforcement/task_lifecycle.py b/roboco/enforcement/task_lifecycle.py index f979ad21..4ef4bb4f 100644 --- a/roboco/enforcement/task_lifecycle.py +++ b/roboco/enforcement/task_lifecycle.py @@ -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 diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index 8e400127..1a50e60f 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -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,26 +186,35 @@ 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: - unknown = [verb for verb in allowed if verb not in _TOOLS] - if unknown: - log.warning( - "do_server: manifest references unknown do tools", - role=AGENT_ROLE, - missing=sorted(unknown), - ) - names = [verb for verb in allowed if verb in _TOOLS] + 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( + "do_server: manifest references unknown do tools", + role=AGENT_ROLE, + missing=sorted(unknown), + ) + names = [verb for verb in allowed if verb in _TOOLS] for verb in names: mcp.tool(name=verb)(_TOOLS[verb]) diff --git a/roboco/mcp/flow_server.py b/roboco/mcp/flow_server.py index bda1ce34..e488f67a 100644 --- a/roboco/mcp/flow_server.py +++ b/roboco/mcp/flow_server.py @@ -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// path.""" - return f"/api/v2/flow/{AGENT_ROLE}/{verb}" + """Build the role-scoped /api/v2/flow// 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,26 +322,38 @@ 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: - unknown = [verb for verb in allowed if verb not in _TOOLS] - if unknown: - log.warning( - "flow_server: manifest references unimplemented verbs", - role=AGENT_ROLE, - missing=sorted(unknown), - ) - names = [verb for verb in allowed if verb in _TOOLS] + 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( + "flow_server: manifest references unimplemented verbs", + role=AGENT_ROLE, + missing=sorted(unknown), + ) + names = [verb for verb in allowed if verb in _TOOLS] for verb in names: mcp.tool(name=verb)(_TOOLS[verb]) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index f3b02da9..5030a7e7 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -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("=20 chars>") - — stages tracked changes; auto-prefixes task ID. Repeat per chunk. -2. i_have_committed("") - — 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=""): -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="") +to submit for QA review. If you hit something you can't unblock yourself: i_am_blocked(task_id="...", diff --git a/roboco/services/gateway/choreographer/__init__.py b/roboco/services/gateway/choreographer/__init__.py new file mode 100644 index 00000000..58af1813 --- /dev/null +++ b/roboco/services/gateway/choreographer/__init__.py @@ -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"] diff --git a/roboco/services/gateway/choreographer.py b/roboco/services/gateway/choreographer/_impl.py similarity index 74% rename from roboco/services/gateway/choreographer.py rename to roboco/services/gateway/choreographer/_impl.py index e6c0035b..acc7f39c 100644 --- a/roboco/services/gateway/choreographer.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -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: " ()" 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. - t = await self.task.claim(task_id, agent_id) + # 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: + 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( - 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, - ), + 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=['', ...]", - 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=['', ...]" - " 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. diff --git a/roboco/services/gateway/choreographer/board.py b/roboco/services/gateway/choreographer/board.py new file mode 100644 index 00000000..6d6f2cc4 --- /dev/null +++ b/roboco/services/gateway/choreographer/board.py @@ -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] + ) diff --git a/roboco/services/gateway/choreographer/doc.py b/roboco/services/gateway/choreographer/doc.py new file mode 100644 index 00000000..eeb7f697 --- /dev/null +++ b/roboco/services/gateway/choreographer/doc.py @@ -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=['', ...]" + " 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] + ) diff --git a/roboco/services/gateway/choreographer/qa.py b/roboco/services/gateway/choreographer/qa.py new file mode 100644 index 00000000..23356b36 --- /dev/null +++ b/roboco/services/gateway/choreographer/qa.py @@ -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=['', ...]", + 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] + ) diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index 2580ac07..38e79a62 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -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={}, ) diff --git a/roboco/services/gateway/role_config.py b/roboco/services/gateway/role_config.py index 21f08434..f582ac87 100644 --- a/roboco/services/gateway/role_config.py +++ b/roboco/services/gateway/role_config.py @@ -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", diff --git a/roboco/services/git.py b/roboco/services/git.py index ffc2518b..3c8c40c9 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -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) diff --git a/roboco/services/task.py b/roboco/services/task.py index 41dadc31..44e9fed7 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -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: - await self._ensure_branch_for_task(task, agent_id) + 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,15 +4733,28 @@ 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) - await self.session.flush() + # 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") async def unblock_with_restore( diff --git a/scripts/regenerate_verb_tables.py b/scripts/regenerate_verb_tables.py new file mode 100644 index 00000000..3fdf7911 --- /dev/null +++ b/scripts/regenerate_verb_tables.py @@ -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 = [ + "", + "", + "", + "# 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 = ( + "\n" + "\n\n" + "## Verbs available to you (autogenerated source of truth)\n\n" + ) + body = _render_role_section(role) + # Strip the leading `## ` 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() diff --git a/tests/integration/test_full_lifecycle_real_db.py b/tests/integration/test_full_lifecycle_real_db.py new file mode 100644 index 00000000..826c145c --- /dev/null +++ b/tests/integration/test_full_lifecycle_real_db.py @@ -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. diff --git a/tests/integration/v2/test_full_pending_to_completed.py b/tests/integration/v2/test_full_pending_to_completed.py index b6879429..0ec27341 100644 --- a/tests/integration/v2/test_full_pending_to_completed.py +++ b/tests/integration/v2/test_full_pending_to_completed.py @@ -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" diff --git a/tests/property/test_state_machine_invariants.py b/tests/property/test_state_machine_invariants.py new file mode 100644 index 00000000..adcac2e0 --- /dev/null +++ b/tests/property/test_state_machine_invariants.py @@ -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" + ) diff --git a/tests/unit/api/routes/v2/test_flow_dev.py b/tests/unit/api/routes/v2/test_flow_dev.py index b2f55d7a..68dacd93 100644 --- a/tests/unit/api/routes/v2/test_flow_dev.py +++ b/tests/unit/api/routes/v2/test_flow_dev.py @@ -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, ) diff --git a/tests/unit/api/test_correlation_id.py b/tests/unit/api/test_correlation_id.py index 613eb283..f8232ec6 100644 --- a/tests/unit/api/test_correlation_id.py +++ b/tests/unit/api/test_correlation_id.py @@ -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) diff --git a/tests/unit/api/test_v2_role_dep.py b/tests/unit/api/test_v2_role_dep.py index a2c67b60..51274547 100644 --- a/tests/unit/api/test_v2_role_dep.py +++ b/tests/unit/api/test_v2_role_dep.py @@ -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"}, ) diff --git a/tests/unit/gateway/test_audit_on_rejection.py b/tests/unit/gateway/test_audit_on_rejection.py index 459e316d..92791f82 100644 --- a/tests/unit/gateway/test_audit_on_rejection.py +++ b/tests/unit/gateway/test_audit_on_rejection.py @@ -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() diff --git a/tests/unit/gateway/test_choreographer_dev.py b/tests/unit/gateway/test_choreographer_dev.py index cdc5ca97..dfe85090 100644 --- a/tests/unit/gateway/test_choreographer_dev.py +++ b/tests/unit/gateway/test_choreographer_dev.py @@ -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 diff --git a/tests/unit/gateway/test_choreographer_submit_qa_gates.py b/tests/unit/gateway/test_choreographer_submit_qa_gates.py index bde50b86..e840e7a3 100644 --- a/tests/unit/gateway/test_choreographer_submit_qa_gates.py +++ b/tests/unit/gateway/test_choreographer_submit_qa_gates.py @@ -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.""" diff --git a/tests/unit/gateway/test_commit_prefix.py b/tests/unit/gateway/test_commit_prefix.py index c77f2bd9..4c4730d8 100644 --- a/tests/unit/gateway/test_commit_prefix.py +++ b/tests/unit/gateway/test_commit_prefix.py @@ -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") diff --git a/tests/unit/gateway/test_content_actions.py b/tests/unit/gateway/test_content_actions.py index 05fa967b..a81b08e1 100644 --- a/tests/unit/gateway/test_content_actions.py +++ b/tests/unit/gateway/test_content_actions.py @@ -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") diff --git a/tests/unit/gateway/test_content_actions_ownership.py b/tests/unit/gateway/test_content_actions_ownership.py index 24f47e80..cc0e8fcf 100644 --- a/tests/unit/gateway/test_content_actions_ownership.py +++ b/tests/unit/gateway/test_content_actions_ownership.py @@ -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") diff --git a/tests/unit/gateway/test_heartbeat_wired.py b/tests/unit/gateway/test_heartbeat_wired.py index 09bd7e3b..993b8707 100644 --- a/tests/unit/gateway/test_heartbeat_wired.py +++ b/tests/unit/gateway/test_heartbeat_wired.py @@ -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() diff --git a/tests/unit/mcp_servers/test_do_server.py b/tests/unit/mcp_servers/test_do_server.py index d13f818d..22b08a4a 100644 --- a/tests/unit/mcp_servers/test_do_server.py +++ b/tests/unit/mcp_servers/test_do_server.py @@ -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 diff --git a/tests/unit/mcp_servers/test_flow_server.py b/tests/unit/mcp_servers/test_flow_server.py index 18720e23..06d485be 100644 --- a/tests/unit/mcp_servers/test_flow_server.py +++ b/tests/unit/mcp_servers/test_flow_server.py @@ -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//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", diff --git a/tests/unit/scripts/test_bash_guard.py b/tests/unit/scripts/test_bash_guard.py index 8be73543..93ef3837 100644 --- a/tests/unit/scripts/test_bash_guard.py +++ b/tests/unit/scripts/test_bash_guard.py @@ -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: diff --git a/tests/unit/services/test_task.py b/tests/unit/services/test_task.py index 9bb31957..3b986a72 100644 --- a/tests/unit/services/test_task.py +++ b/tests/unit/services/test_task.py @@ -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 # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 6147a394..921fcf6d 100644 --- a/uv.lock +++ b/uv.lock @@ -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]] diff --git a/vulture_whitelist.py b/vulture_whitelist.py deleted file mode 100644 index 23aba939..00000000 --- a/vulture_whitelist.py +++ /dev/null @@ -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.