Chore: v1 removal gateway canonical (#46)

* chore(agent_sdk): remove dead /traceability/remind endpoint and reminder map

The TRACEABILITY_REMINDERS dict and its /traceability/remind endpoint were
keyed entirely on pre-gateway tool names (roboco_task_*, roboco_journal_*,
roboco_message_send, roboco_session_create_for_tasks) deleted in the gateway
cutover. The endpoint had zero callers; v2 enforces traceability server-side
in the Choreographer.

* fix(bootstrap,seeds): onboarding prompts call give_me_work(), not deleted roboco_task_scan()

The startup prompt and the seeded cell/all-hands channel onboarding messages
instructed agents to call roboco_task_scan() — a tool removed in the gateway
cutover. Point them at the live give_me_work() flow verb.

* fix: replace remaining deleted v1 tool names with gateway verbs

Spawn prompts, onboarding strings, remediation messages, and comments still
referenced pre-gateway tools deleted in the cutover (roboco_task_*,
roboco_agent_idle, roboco_notify_*, roboco_message_send,
roboco_session_create_for_tasks, roboco_journal_*, roboco_escalate). Rewrote
each to the correct role-scoped gateway verb (give_me_work/i_will_work_on for
workers, triage for PMs, i_am_done vs complete, notify/notify_ack, escalate_up,
unclaim, i_documented, open_session, note). Updated one enforcement-message
test that matched the old tool name by coincidence.

* test: guard against deleted v1 tool names reappearing in roboco/

Scans roboco/ for the deleted pre-gateway tool names; excludes the orphaned
roboco/agents/ subtree (removed in a later phase).

* chore(exceptions): drop 8 unused pre-gateway exception classes + their tests

LLMError, RAGError, AlreadyExistsError, TaskBlockedError, TaskClaimError,
AgentNotAvailableError, AgentBusyError, NotificationPermissionError were never
raised in production. SessionClosedError/DatabaseError are kept (live + tested).

* chore(models): drop unused pre-gateway notification/channel/handoff factories

Removes create_task_assignment/_blocker_escalation/_review_request/
_documentation_request/_priority_change/_alert/_broadcast, create_cell_channel/
_cross_cell_channel/_announcements_channel, create_handoff (+ HandoffParams),
ProactiveContext, and A2APartType. The gateway choreographer builds these
server-side now. Drops the matching dead-code tests.

* chore(services): drop unused pre-gateway permission/messaging/audit/optimal/remediation methods

These pre-gateway helpers (channel-permission checks, channel-membership ops,
permission-denial audit hooks, doc ingestion, two remediation hints) have no
production caller — the gateway role_config + enforcement layer replaced them.
Drops the matching dead-code tests; live methods (send_message, the SESSION_*
flow, log_task_action_denial, etc.) are untouched.

* chore(orchestrator,ws,events,config): drop unused pre-gateway lifecycle/broadcast/roster symbols

orchestrator: get_running_agents, is_agent_busy, queue_priority_work,
get_all_instances (+ their OrchestratorAccessProtocol declarations in events.py).
websocket: broadcast_new_message, broadcast_session_closed (no event type emits
them). agents_config: ALL_PMS/ALL_DEVS/ALL_QA/CELL_PMS roster constants (ALL_DOCS
stays — it gates docs-write workspace perms).

* refactor(agents): delete orphaned pre-gateway agent subtree + dead organization model

The Gateway/full cutover replaced the Python agent-class implementations with
the server-side Choreographer; the classes survived only as a self-referential
island. Removes roboco/agents/{base,mixins,factory,board,developer,documenter,
pm,qa,orchestrator}.py and roboco/agents/factories/{board,cells,developers,
documenters,pms,qa}.py, plus roboco/models/organization.py (Cell/Board/
Organization — used only by those factories). Keeps factories/_base.py
(compose_prompt — the live prompt-layer composer the orchestrator calls at
spawn) behind minimal package __init__ files.

* chore(db): drop dead tasks.execution_log + outputs columns (migration 015)

Both JSON columns had zero readers/writers in code, tests, and migrations —
execution progress is tracked via progress_updates and artifacts via
commits/documents. Removes the ORM columns, the Pydantic Task.execution_log/
outputs fields, the ExecutionLog/FileRef models (+ their __init__ exports), and
the now-invalid kwargs from test fixtures. Migration 015 (down_revision
014_drop_pm_approvals) verified live: upgrade drops, downgrade re-adds.
Apply on the NAS with 'alembic upgrade head' at next deploy.

* chore(config): drop 16 unread Settings fields

Verified unused (no settings.X, no self.X property use, no getattr-by-name):
app_name, reload, workers, openai_api_key, secret_key, access_token_expire_minutes,
algorithm, log_level, log_format, the four session_* limits, message_max_length,
commit_subject_min_chars, commit_banned_words, agent_budget_sweep_interval_seconds.
Removes the empty Logging + Sessions&Messages sections and orphaned .env.example
vars. Kept: redis_db/redis_password (redis_url property), agent_sla_* (read via
getattr in task_lifecycle), encryption_key, and all live thresholds.

NOTE: commit_banned_words/commit_subject_min_chars and
agent_budget_sweep_interval_seconds were feature-config never wired to their
consumer (commit validator / budget sweep) — removed as dead, but flagged in
case the intent was to wire them.

* test(lifecycle): give i_will_work_on calls a substantive plan (#171 contract)

The real-DB lifecycle tests called i_will_work_on with a 13-char plan and no
risks/technical_considerations, so the substantive-plan gate (#171) rejected
them with incomplete_input — failing on master. Supply a >=150-char plan plus
technical_considerations and risks (mirroring tests/unit/gateway/
test_choreographer_dev.py). All 6 now pass; gate runs with no deselect.

* feat(gateway): wire commit-validator thresholds to settings

commit_subject_min_chars and commit_banned_words were config defined but never
read — the gateway commit() gate used the validator's hardcoded module defaults.
Re-add the two Settings fields and pass them through validate_commit_message in
content_actions.commit(), so config is the source of truth (validator defaults
remain the standalone/CI fallback). Adds wiring tests that monkeypatch settings
and assert the gate honors them.

* refactor(orchestrator): retire gateway_enabled flag; trigger_filter is unconditional

The gateway_enabled Settings field gated only the trigger_filter spawn-cooldown
(never the agent tool surface). Prod ran it on; the Phase-0 'legacy dispatch
path' it guarded no longer exists. Remove the field + the early-return branch in
gateway_pre_spawn_check so the cooldown runs for every spawn, drop the now-dead
ROBOCO_GATEWAY_ENABLED from docker-compose.yml, and update the stale Phase-0
comments + cooldown test. The per-container ROBOCO_GATEWAY_ENABLED env (set by
_append_manifest_args, read by agent_sdk to load the manifest) is unaffected.

* refactor(api): relabel /api/v2 -> /api/v1 as the canonical gateway surface

The gateway is the only agent API now, so the 'v2' label (with no v1) was
misleading. Renames roboco/api/routes/v2 -> routes/v1, schemas/v2 -> schemas/v1
(+ the matching test dirs and test_v2_role_dep/test_schemas_v2_flow files),
rewrites every /api/v2 path, routes.v2/schemas.v2 import, and v2-* router tag to
v1, and refreshes the stale 'v2' comments/docstrings. The panel is untouched (it
uses the unversioned /api/* REST routes). flow_server/do_server now POST to
/api/v1/*.

* docs(scripts): reset_runtime_state header matches actual SQL behavior

The header claimed it preserves groups + journals, but the .sql wipes both
(verified live: groups 6->0, journals 5->0; only agents/projects/channels
survive). Correct the wiped/preserved lists to match.

* refactor(gateway): extract _build_rich_plan to drop i_will_work_on under the complexity gate

i_will_work_on was cyclomatic rank C (11) — one over the xenon --max-absolute B
threshold — because of the five `x or default` fallbacks in the rich_plan dict.
Move that dict into a small _build_rich_plan helper (behaviour identical); both
methods are now rank B. make quality is fully green (xenon was its last failure;
bandit already passed — its 34 findings are all LOW severity, filtered by -ll).

* feat(foundation): add canonical CELL_TEAMS set; dedupe cell-subset literals

* feat(db): add ProductTable + ProductProjectTable ORM (per-cell project map)

* feat(task): add additive nullable product_id (ORM + model + DTO + create threading)

* feat(task): thread product_id through create_subtask/route/response

* feat(db): migration 016 — products, product_projects, tasks.product_id

* fix(db): document migration 016 plan deviations (revision len, FK name)

Two values in migration 016 intentionally diverge from the Task 2.4 plan
literals; this strengthens the in-file justification so the deviations are
self-documenting and verifiable.

- revision id (plan line 623): the plan's 36-char
  "016_add_products_and_task_product_id" overflows alembic_version.version_num
  (VARCHAR(32)) — alembic upgrade head raises asyncpg
  StringDataRightTruncationError. Kept at 27 chars
  ("016_add_products_product_id") so Step 4's live round-trip stays green.
- downgrade FK name (plan line 683): roboco/db/base.py sets a metadata
  naming_convention, so the FK upgrade() creates is
  "fk_tasks_product_id_products", not the Postgres default
  "tasks_product_id_fkey". The plan literal does not exist in the DB and
  would fail the downgrade with "constraint does not exist".

Both verified via the live upgrade/downgrade round-trip on a throwaway DB.

Issue 3 note: the prior commit (b896cac) also touched
tests/unit/api/test_schemas_tasks.py (added product_id=None to the
task_to_response stub). That line is load-bearing — task_to_response reads
task.product_id (added in Task 2.3, commit 67afa6b) — and belongs to Task 2.3's
scope; it is left in place because removing it breaks 4 tests and history is
not rewritten.

* refactor(db): trim migration 016 deviation notes to plan-faithful form

Reverts the out-of-scope documentation expansion (commit 1a4f296), which
was a second undocumented commit beyond Task 2.4's single plan-specified
commit and only bloated the migration docstring/comments.

The migration file now matches the plan-specified commit (b896cac) byte for
byte: the two necessary deviations from the plan literals stay (revision id
shortened to fit alembic_version.version_num VARCHAR(32); downgrade FK name
follows db/base.py's metadata naming_convention), each kept to a concise
inline note in the plan's header style.

The Task 2.3-scoped test stub line (tests/unit/api/test_schemas_tasks.py
product_id=None) is load-bearing — task_to_response reads task.product_id —
and is left in place; history is not rewritten.

Verified: live alembic upgrade head + downgrade to 015 round-trip on a
throwaway DB drops products/product_projects/tasks.product_id cleanly, and
make quality is green.

* refactor(test): annotate db_session and drop type: ignore in migration 016 test

Annotate the test_products_tables_and_task_fk_exist param as
db_session: AsyncSession (imported under TYPE_CHECKING) and remove the
# type: ignore[no-untyped-def] suppression, matching the typed db_session
pattern used across tests/integration/.

* feat(models): Product + ProductCreate/Update + ProductCellMapping (cell-validated)

* refactor(models): minimize ProductCellMapping config override to use_enum_values

The previous override re-declared validate_assignment, populate_by_name,
and extra=forbid, which RobocoBase already supplies. Pydantic merges
model_config across inheritance, so overriding only use_enum_values=False
is sufficient to keep team as a real Team enum (required so team in
CELL_TEAMS and enum identity hold for callers) while inheriting the rest
of the base config.

* fix(models): document ProductCellMapping use_enum_values override as plan-mandated

Resolves SPEC-COMPLIANCE review notes for Task 3.1 (Product domain models).

1. The ProductCellMapping use_enum_values=False override is a deviation from a
   bare project.py mirror, but it is mandated by the plan's own Task 3.1 code:
   RobocoBase sets use_enum_values=True, which coerces team to the plain string
   "backend". The plan's Step 1 test asserts m.team is Team.BACKEND (enum
   identity) and the Step 3 validator formats its error with v.value, both of
   which require team to remain a real Team enum. The override is therefore
   necessary; this commit relabels the comment to cite the specific spec lines
   that force it instead of leaving it as an unexplained departure. Downstream
   Task 3.2 (_replace_cells / project_for) already tolerates either form and the
   ORM stores the same value regardless, so the override has no behavioral reach
   beyond the in-memory enum identity the plan's test checks.

2. test_product_model.py hoists 'from uuid import uuid4' to module level rather
   than inline (as the plan's verbatim Step 1 code shows) because the global
   Pylint PLC0415 rule (import-outside-top-level) forbids inline imports and
   there is no per-file-ignore for tests/unit/models/. The hoisted form is the
   only ruff-clean rendering of the plan's test; left unchanged here.

3. Task 3.1 landed across two commits (c616d95 create, 6ebad255 refactor) rather
   than the plan's single Step 5 commit. Earlier history is intentionally not
   rewritten; this single follow-up commit brings the model to its final
   spec-faithful, fully-documented state.

* feat(service): ProductService CRUD + project_for per-cell resolver

* feat(api): Product CRUD routes + schemas, wired into the app

* fix(api): roll back and map cell-replacement IntegrityError on product update

update_product replaced cells via ProductService._replace_cells without
any try/except, so a duplicate-team cell (uq_product_projects_product_team)
or a non-existent project_id (product_projects.project_id FK) raised an
IntegrityError at flush, poisoning the AsyncSession and surfacing an
unhandled 500 with no rollback. Wrap the update + commit in a try/except
that rolls back and maps the UNIQUE violation to 409 and the FK violation
to 422, mirroring create_product's rollback discipline. Add integration
tests covering both client-error paths.

* fix(api): map create_product cell-mapping IntegrityError to 409/422

create_product only caught the slug conflict ('already exists' in str(e))
and bare-raised everything else, so a cells entry whose project_id does not
reference any project let the product_projects.project_id FK IntegrityError
propagate out of the route as an unhandled 500. The matching update_product
path was already hardened (uq_product_projects_product_team -> 409, FK
violation -> 422); apply the same mapping in create_product so a bad
project_id (or a duplicate-team cell) is a client error, not a server error.
The slug conflict is now caught as ConflictError directly instead of via a
broad except + string match.

* feat(gateway): add optional project_id to delegate inputs/request/routes

* feat(gateway): per-cell project routing (override -> product map -> parent) + product_id inheritance

* feat(task): approve_and_start — reassign board task to Main PM (CEO gate #1)

* feat(api): POST /tasks/{id}/approve-and-start (CEO gate #1, notes-required)

* test(api): cover approve-and-start 404-before-notes-gate for missing task

* feat(panel): Product types + Task.product_id

* feat(panel): productsApi + hooks + tasksApi.approveAndStart

* feat(panel): Products management screen + sidebar nav

* feat(panel): Approve & Start button (CEO gate #1)

* fix(api): narrow delete_product to IntegrityError + cover 204/409 delete paths

* test(task): assert approve_and_start persists + appends the audit note

* refactor(db): migration 016 names the tasks.product_id FK explicitly (house style)

* fix(db): make migrations authoritative + self-heal orphan product tables

init_db() no longer silently falls back to create_all when alembic upgrade
fails. That fallback masked migration failures and, since create_all cannot
ALTER an existing table, left the schema inconsistent — turning an unapplied
migration 016 into a crash loop: 016's CREATE TABLE products failed, the
upgrade rolled back, create_all re-created an empty orphan products table, and
every later boot failed again on the now-existing table while tasks.product_id
never got added. Now a migration failure is raised so the real error surfaces.

Migration 016 additionally drops EMPTY orphan products/product_projects tables
left by the old fallback before creating them, so an already-polluted DB
self-heals on the next deploy with no manual SQL. Skipped in offline (--sql)
mode; refuses to drop a table that holds rows.

* fix(db): create_all is the schema source of truth; alembic for increments

The Alembic chain is incomplete relative to the ORM — columns/tables like
notifications.delivered_at and the RAG indexed_documents table have NO migration
and have only ever been materialized by create_all. Tests don't catch this
because the test DB is also built via create_all, so migrations are never
exercised. The prior 'migrations are authoritative' init_db (and before it, the
create_all-only-on-failure fallback) therefore left a migrate-only boot with
missing columns/tables.

init_db now reflects reality:
  - Fresh DB  -> create_all builds the full current ORM schema, then stamp
                 Alembic at head so later incremental migrations apply.
  - Existing  -> run pending migrations (a real failure is raised, not masked),
                 then create_all(checkfirst) to gap-fill any missing ORM tables.
create_all cannot add a column to an existing table, so an ORM column added
without a migration needs a fresh rebuild of that table to appear.

* fix(db): migration 017 reconciles the Alembic chain with the full ORM schema

For years the live schema was built by create_all, not migrations, so the chain
drifted — tables/columns/indexes in the ORM had no migration (the
indexed_documents table, notifications.delivered_at, ~15 indexes, plus
timestamptz/server-default metadata). With init_db no longer masking that via a
create_all fallback, a migrate-only boot was missing those objects.

017 was produced by 'alembic revision --autogenerate' against Base.metadata,
reviewed, and verified: on a fresh DB, 'alembic upgrade head' (001..017) now
reproduces the create_all schema EXACTLY — a re-run of autogenerate detects zero
changes — and the 017 upgrade/downgrade round-trips cleanly. The migration chain
is now complete: migrate-only and create_all converge.

Also updates the init_db tests to assert the new behaviour (raise on an existing
DB's migration failure; create_all + stamp head on a fresh DB) instead of the
removed silent fallback.

* feat(panel): Product picker in the New Task form (drives per-cell routing)

The Products screen and Approve & Start button shipped, but the task-creation
form had no way to attach a Product — so a human couldn't set product_id from
the UI, which is exactly what drives per-cell project routing of delegated
subtasks. Adds an optional Product dropdown (Advanced -> Git config) populated
from useProducts(); 'None' falls back to the single project.

* fix(db): seed data is preserved on a fresh DB (run migrations, not bare create_all)

The previous fresh-DB path (create_all + stamp head) built the tables but never
ran the migration chain, so migration-embedded SEED DATA was skipped — most
visibly the AI providers seeded in 004. After a DB reset that left
provider_configs empty, so PUT /api/providers/ollama-key 404'd (the handler
raises NotFoundError when the Ollama provider row is missing).

Since migration 017 made the chain reproduce the full ORM schema, init_db now
runs 'alembic upgrade head' from base on a fresh DB — building every
table/column/index AND running the seeds. Verified: a fresh upgrade head seeds
both provider rows. Existing DBs still get migrations + create_all gap-fill.
Updates the init_db fresh-DB test accordingly.

* feat(task): project_id optional when a product_id is set (board fan-out tasks)

A board task that fans out across cells via a Product has no single repo of its
own — backend/frontend/ux_ui are each wrong, because the root coordinates and
delegates. Forcing one arbitrary Project was broken design (flagged at design
time). project_id is now nullable; a task must have project_id OR product_id:
  - TaskCreate model validator + a TaskService.create() invariant (covers every
    create path).
  - ORM/DTO/schema: project_id nullable; task_to_response uses to_python_uuid.
  - Gateway: a parent with only a product can delegate (guard now needs BOTH
    project and product to be None to reject); _resolve_subtask_project resolves
    each subtask from the product map and raises a clear error if a cell has no
    mapping and no parent project.
  - Migration 018 (tasks.project_id nullable), round-trip verified; fresh
    upgrade head still seeds providers.
  - Panel: Project no longer required once a Product is selected.
  - Removed the dead, never-called a2a create_task_from_message (it could only
    ever create a repo-less task) + its two coverage-only tests.

make quality green; panel tsc/lint/build green.

* Upgrade to Minimax M3

* fix(db): seed providers on existing DBs + correct enum casing

Migration 004 created the modelprovider/assignmentscope enums and seeded
provider rows in UPPERCASE, but the ORM (_str_enum) reads/writes the
lowercase StrEnum .value — so a fresh migrate-from-base DB built an enum
the ORM cannot read. Lowercase the enum labels and seed values in 004.

Add idempotent migration 019 to (re)seed the Anthropic + Ollama Cloud
providers with ON CONFLICT (name) DO NOTHING, so an existing DB whose
provider_configs table was created by create_all (and never ran 004's
seed) gets the rows on the next `alembic upgrade head` — fixing the
/api/providers/ollama-key 404 without a volume wipe.

* fix(tasks): let board/fan-out coordination tasks flow without a repo

A coordination task (project_id NULL, product_id set) targets no repo of
its own — it fans out to cell subtasks that each resolve a real project
from the product's cell->project map. Several paths still assumed every
task does git work and blocked it:

- orchestrator: add _is_coordination_task() and exempt these tasks from
  the project/branch/git-token gates in _readiness_check_task,
  _readiness_gate, _check_stuck_conditions, _validate_task_for_spawn.
- services/task.py: _ensure_branch_for_task returns "" (no branch) for a
  coordination task instead of raising; activate requires project OR
  product. This unblocks Main PM's i_will_plan claim, which otherwise
  raised before it could delegate the fan-out.
- gateway: _pending_assignment_guard exempts advisory roles
  (product_owner/head_marketing/auditor) from the "assigned but never
  claimed" idle gate — they review without claiming, so they could not
  satisfy a claim-or-unclaim remediation.

Adds focused unit tests for each.

* fix(tasks): coordination tasks reach in_progress + team reflects Main PM

The board->cells fan-out deadlocked: a coordination/fan-out task (product set,
no project of its own) could be created and claimed, but start()'s
claimed->in_progress transition hit validate_git_requirements, which still
demanded a branch_name and raised GitRequirementError. So Main PM's i_will_plan
never completed — it looped and never delegated. c961282 exempted
_ensure_branch_for_task (branch creation) but missed this parallel git gate in
the enforcement layer.

- task_lifecycle.py: add GitContext.is_coordination; skip the
  claimed->in_progress branch_name gate when it is set.
- task.py: populate is_coordination=(project_id is None and product_id is not
  None) in _validate_and_set_status; a branchless code task is still gated.
- approve_and_start: set team=Team.MAIN_PM on hand-off so the task isn't left
  labelled team=board after it leaves the board (now assigned to main-pm).

Adds a lifecycle-gate unit test and an end-to-end integration test that
claims, plans, and starts a project-less coordination task.

* fix(hooks): remove dead traceability hook + stale deleted-verb references

The v1-removal cleanup (2cfbf39) deleted the /traceability/remind SDK endpoint
but left the PostToolUse hook that curls it, so every gateway tool call 404'd
and agents silently lost their traceability reminders. Remove the dangling hook
(registration + TRACEABILITY_TRIGGER_TOOLS + Dockerfile COPY + the script); v2
carries per-verb guidance on the Envelope. Also correct two stale pre-gateway
tool names in hook text: the budget loop-detector nudged agents toward the
deleted roboco_task_escalate() (now unclaim()/i_am_idle(), which every looping
role has), and an sdk-startup comment referenced roboco_task_scan/get.

Extends the deleted-tool-name guard to scan docker/scripts/*.sh and to assert
every $SDK_URL/<path> a hook curls is a route still served by the SDK — the
check that would have caught this class (it lives in shell, invisible to mypy
and the Python import graph).

* fix(db): backfill ORM enum values the migration chain never added

Several StrEnum values were added to the ORM over time without a matching
`ALTER TYPE ... ADD VALUE` migration; 017 was autogenerate-derived and
autogenerate does not detect added enum labels, so the drift survived. On a DB
whose enum type predates the value, binding it raises at runtime — e.g.
`invalid input value for enum notificationtype: "a2a_request"` on
GET /api/notifications (list_system_notifications), and the same class for
blockerresolvertype/handoffstatus/team.

Migration 020 adds every drifted value idempotently (ADD VALUE IF NOT EXISTS —
no-op when 009 already reconciled it). Runs on the next `alembic upgrade head`.

Detected by comparing each ORM enum's values to the labels the migration chain
produces; adds tests/unit/test_enum_migration_parity.py which renders the chain
offline and fails on any future drift — the check that would have caught both
this and the provider-enum bug.

* fix(orchestrator): stop branch auto-block, board reassign, unblock livelock, agentless claims

Cluster C1 — four coupled orchestrator/task-invariant defects:

#18: a branch is created only at claim, so a pending, never-claimed code task
legitimately has no branch_name. The stuck-detection sweep (pending-only) and
readiness gate flagged that as "Task missing branch_name" and auto-blocked the
task every tick, so it never dispatched. Centralize the gate in
_branch_is_expected (status in claimed/in_progress/verifying, never a
coordination task) and apply it in both _check_stuck_conditions and
_readiness_check_task.

#14: the main_pm -> product_owner escalation rung handed an in_progress
descendant code task to the Product Owner (a board role) and marked it BLOCKED;
the board has no verb to own code work, so the dev's finished work deadlocked.
TaskService.apply_escalation (the single write primitive — covers both the
gateway escalate verb and the HTTP escalate route) now diverts a descendant code
task targeting a board/advisory role: it releases the task to PENDING for a
role-matched cell claim instead of stranding it.

#17: a blocked task reassigned to Main PM kept respawning the ex-assignee cell
PM to unblock it, but the assignee-only pre-unblock note returned not_authorized
— a livelock. _dispatch_blocker_work now dispatches the task's CURRENT PM/board
assignee (the unblock authority), falling back to the cell PM only when no
PM/board holds it. Also: a branchless coordination parent yields no valid merge
target — resolve_parent_branch now falls back to the child's own project default
branch (e.g. master) via TaskService.project_default_branch_for_task, and
_check_parent_branch_ready no longer blocks a child on a coordination parent's
non-existent branch.

#19: a task left claimed/in_progress with an assignee but no running container
was invisibly stuck (only PENDING tasks get fresh dispatch; the heartbeat reaper
can't see a freshly-seeded claim). New _dispatch_claimed_without_agent net:
after a short grace window it respawns the assignee, or releases the claim to
pending (lifecycle-safe via unclaim_for_reaper) when the assignee is unknown.
New config ROBOCO_CLAIMED_NO_AGENT_GRACE_SECONDS (default 120).

* fix(gateway): tolerant note verb + lock evidence do-tool invariant

#15: the note verb no longer hard-rejects thin decision/reflect payloads.
List-typed fields (options, consequences, next_steps) coerce a lone scalar
into a one-element list at both the NoteRequest schema (mode=before
validator) and the service layer; missing narrative fields default to a
visible placeholder instead of returning incomplete_input. The note is
always recorded, preserving audit value, and a well-intentioned note can no
longer trip the do-server 3-strikes circuit breaker. Widen the agent-facing
do_server.note hints to accept list-or-scalar and refresh the docstrings.

#8: add regression coverage locking the invariant that every role's do_tools
carries evidence (role_config + developer spawn manifest). The current source
already registers mcp__roboco-do__evidence for developers end-to-end; the
report stemmed from a stale deployed build, and the tests prevent silent
regression.

* fix(gateway): allow UX devs to receive design tasks; surface delegation rules to cell PM

The UX/UI cell's developers (ux-dev-1/ux-dev-2, Role.DEVELOPER on
Team.UX_UI) ARE its designers, but _validate_assignee_task_type rejected
task_type='design' for every DEVELOPER, blocking the UX cell's normal
design delegation. Allow 'design' for UX-team devs only; backend/frontend
devs stay rejected (design routing belongs to the UX cell). The
orchestrator already dispatches a developer for a design task
(_dev_dispatch_role_matches returns True), so this creates no orphan like
the documentation case.

Replace the static Cell-PM 'pass planning' remediate with a per-assignee
hint so a dev/design mis-type gets a developer-class next-step instead of
an off-topic planning hint.

Surface the three delegation guardrails in the cell-PM prompt so PMs stop
probing them by trial and error: valid task_type per assignee (incl.
design for UX devs), documentation auto-creation (non-delegatable), and
the sequential single-active code-spine. Fix the delegate-row task_type
list (documentation is NOT delegatable) and update the lifecycle spec
description; regenerate the lifecycle artifacts.

* fix(orchestrator): improve agent briefings for handoff consumption, product/project model, and workspace/secret hygiene

Main PM (roles/main_pm.md):
- Require reading the upstream Product Owner / Head of Marketing handoff
  (their decision/reflect journal entries + task description) BEFORE doing
  any own research or calling i_will_plan, so the Main PM builds on the
  Board's analysis instead of duplicating it. Added a dedicated section,
  hardened workflow step 1, and added an anti-pattern.
- Add a 'Products vs Projects' section: a Product fans out to one Project
  per cell; those Projects may be the SAME repo (monorepo subtrees) or
  DIFFERENT repos (multi-repo). The Main PM coordinates across them and
  must not assume one repo or call a monorepo subtree 'a separate repo'.
  Names the Prompter monorepo case (github.com/rennf93/roboco).

Developer (roles/developer.md):
- State the exact workspace path convention
  /data/workspaces/<project-slug>/<team>/<agent-slug>/, that the cwd is
  already set there, to stay inside the own cell workspace, and to not
  probe/guess the path (ls /, find /).
- Sanctioned secret handling: env/printenv is bash-guard denied and
  reveals nothing; needed secrets arrive via the task description, else
  i_am_blocked so the PM supplies them. Added matching anti-patterns.

Tests: add tests/unit/agents/test_briefing_cluster_c4.py asserting the
composed system prompt (the text mounted into agent containers) carries
each of the above.

* fix(orchestrator): board review involves PO+HoM and notifies CEO

Cluster C5 (#2, #4): a board/coordination task was reviewed by the Product
Owner alone, and the CEO got no formal signal when the review finished —
only buried channel chatter — so the Approve & Start handoff was invisible.

#4 — Board review is now a two-reviewer gate. _handle_board_assigned_task
dispatches BOTH the Product Owner and the Head of Marketing (one-shot each),
regardless of which one holds assigned_to, and the unassigned board-routing
path delegates here instead of claiming + spawning the PO alone. Board tasks
stay pending/unassigned for the CEO's Approve & Start. The board prompt now
makes the PO+HoM pair-review model explicit (HoM owns the UX/positioning
dimension).

#2 — Once BOTH reviewers have finished (dispatched and no longer active),
the orchestrator emits exactly one formal CEO notification via
NotificationService.send_board_review_complete_notification (APPROVAL type,
ack-required, carrying related_task_id) so the handoff is an actionable
signal. One-shot per task; a notification failure clears the guard so a
later tick can retry.

To let the non-assignee board member record its review note on a task held
by the other board member, content-action ownership now exempts a board role
posting to a board/coordination task (project_id is None, product_id set).
The exemption is narrow: it does not widen ownership for any other role or
any project-backed task.

Unit tests cover both reviewers dispatched, one-shot dispatch, the CEO
notification fired exactly once when both are done (and not before), the
retry-on-failure path, the notification builder, and the board co-review
ownership exemption (allowed for board+coordination, blocked otherwise).

* fix(workspace): install dev deps post-clone + raise git commit timeout for large changesets

Cluster C6 (#10, #13, #12-investigate).

#10: per-agent workspace clones never had the project's dev dependencies
installed, so the make-quality gate (ruff/mypy/pytest for Python, the TS
toolchain for the panel) was missing and devs re-downloaded tooling per
task. WorkspaceService now runs the project's install after cloning
(`uv sync` for Python, `pnpm install`/`npm ci`/`npm install` for Node/TS,
detected by manifest/lockfile). Idempotent via a lockfile-digest marker
under .git/ so a re-entry with unchanged lockfiles is a no-op; also runs on
the healthy short-circuit so pre-existing clones get backfilled. Gated by
workspace_install_dev_deps (default on) with workspace_dep_install_timeout_seconds.

#13: the gateway commit verb timed out on the large panel changeset because
every git op used the hardcoded 30s _GIT_TIMEOUT and each call also re-walks
the tree to chown. _run_git now takes a per-call timeout override sourced
from settings (git_command_timeout_seconds default); the staging + commit
ops in commit() and create_commit() use the longer git_commit_timeout_seconds
(default 180s). httpx REST timeouts unchanged in value.

#12 (investigate only — no push, no history change): the clone base ref is
NOT hardcoded; it already comes from project.default_branch threaded through
git.get_workspace -> ensure_workspace -> _clone_repo (git clone --branch).
The stale-base problem is a deploy/process issue (GitHub master is behind the
deployed migration chain), resolvable only by pushing the chain to master.
The default_branch column is the existing configurable lever.

* fix(panel): gate Approve & Start to board coordination tasks; stop 404 storm on closed sessions

CEO gate #1 button only renders for a PENDING board coordination/fan-out
task (no project_id, has product_id) — the board-reviewed handoff that
approve_and_start accepts — instead of every PENDING board-team task.
approve_and_start requires PENDING (it re-targets to Main PM without a
status change), so the gate stays on PENDING rather than the unrelated
end-of-work awaiting_ceo_approval state.

Session/message reads now treat a 404 as terminal and never retry it: a
reaped session is gone for good, and retrying every dead session-id is
what produced the growing 404 storm on GET /api/messages. The transcript
loads once (staleTime Infinity, no focus/reconnect refetch) so closed
sessions stay viewable without re-polling.

* fix(orchestrator): role-correct respawn prompt, throttle agentless dispatch, broaden #14 guard

#19 wrong-role prompt on respawn: _get_prompt_for_agent fell through to the
developer prompt for every non-dev/doc/qa role, so a respawned PM or board
agent was told to write code and call verbs it does not own. Route by the
agent's actual role through the existing per-role prompt builders
(developer/qa/documenter/cell_pm/main_pm/product_owner/head_marketing/auditor).
Both callers benefit; _spawn_pending_dev only ever passes developer/documenter/
unknown, so its behavior is unchanged.

#19 spawn-burst: _dispatch_claimed_without_agent looped over every agentless
claimed/in_progress task and could spawn many containers in one tick. Break
after the first respawn so a restart can't trigger a burst, matching every
sibling dispatcher. The release-to-pending path spawns nothing and keeps
draining stale unknown claims.

#14 guard scope: _is_descendant_code_task only matched CODE, so a descendant
DOCUMENTATION or DESIGN task escalated to a board/advisory role was still
stranded on a role with no verb to own it. Rename to
_is_descendant_executable_task and broaden to CODE/DOCUMENTATION/DESIGN — the
cell-executed types a board role cannot own. PLANNING/RESEARCH/ADMINISTRATIVE
route to a PM, not a cell agent, and are left unchanged; root tasks are still
reviewed up the chain.

* fix(docker): add node+pnpm to orchestrator so it pre-installs frontend cell deps

* Added .github workflows

* refactor(services): extract helpers to keep install_dev_deps + developer task-type check under the xenon complexity gate

* chore(github): add launch kit — CI, GHCR release, labels, templates, funding, dependabot npm, community docs

* chore(github): bump_version — drop unused noqa, fix datetime UTC import

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-03 06:35:03 +02:00
committed by GitHub
co-authored by Renn F
parent f83f930323
commit 110aaa7a77
211 changed files with 10852 additions and 9560 deletions
@@ -0,0 +1,106 @@
"""Cluster C4 — agent briefing/onboarding fixes (findings #3, #5, #9).
These assert on the COMPOSED system prompt (``compose_prompt``), which is
the text actually mounted into agent containers at ``/app/system-prompt.md``
via ``--system-prompt-file``. The three behaviors fixed here:
- #3: Main PM must read the upstream PO/HoM handoff BEFORE its own research,
so it does not duplicate the Board's analysis.
- #5: Main PM's Products-vs-Projects mental model — a Product fans out to
per-cell Projects which may be the SAME repo (monorepo subtrees) or
DIFFERENT repos (multi-repo); the Main PM coordinates across them and
must not assume one repo.
- #9: Developers know their exact workspace path convention, stay inside
their own cell workspace, and obtain secrets via the task description —
never ``env``/``printenv`` (bash-guard denies it).
"""
from __future__ import annotations
from roboco.agents.factories._base import compose_prompt
from roboco.models import AgentRole, Team
def _composed_prompt_for(role: AgentRole, team: Team | None = None) -> str:
return compose_prompt(role, team, agent_slug="test-agent")
# --------------------------------------------------------------------------
# #3 — Main PM consumes the upstream handoff before researching/planning
# --------------------------------------------------------------------------
def test_main_pm_prompt_requires_reading_upstream_handoff_first() -> None:
"""Main PM is told to read the PO/HoM handoff BEFORE its own research."""
prompt = _composed_prompt_for(AgentRole.MAIN_PM)
assert "Read the upstream handoff BEFORE you research or plan" in prompt
# Names both upstream sources explicitly.
assert "Product Owner" in prompt
assert "Head of Marketing" in prompt
def test_main_pm_prompt_forbids_re_researching_already_handed_work() -> None:
"""The duplicated-research failure mode is called out as an anti-pattern."""
prompt = _composed_prompt_for(AgentRole.MAIN_PM)
assert "Re-researching the codebase from scratch" in prompt
# The handoff lives in the task journal as decision/reflect entries.
assert "decision" in prompt and "reflect" in prompt
# --------------------------------------------------------------------------
# #5 — Products-vs-Projects mental model (mono- vs multi-repo)
# --------------------------------------------------------------------------
def test_main_pm_prompt_explains_product_fans_out_to_per_cell_projects() -> None:
"""Main PM prompt distinguishes Product (strategic) from per-cell Projects."""
prompt = _composed_prompt_for(AgentRole.MAIN_PM)
assert "Products vs Projects" in prompt
assert "fans out to one Project per cell" in prompt
def test_main_pm_prompt_covers_both_monorepo_and_multirepo() -> None:
"""Both fan-out shapes are described; neither is assumed by default."""
prompt = _composed_prompt_for(AgentRole.MAIN_PM)
assert "Monorepo" in prompt
assert "Multi-repo" in prompt
# Must not let the agent call a monorepo subtree "a separate repo".
assert "not" in prompt and "a separate repo" in prompt
def test_main_pm_prompt_names_prompter_monorepo_case() -> None:
"""The concrete Prompter case (all cells = one repo) is stated."""
prompt = _composed_prompt_for(AgentRole.MAIN_PM)
assert "github.com/rennf93/roboco" in prompt
# --------------------------------------------------------------------------
# #9 — Developer workspace path convention + secret handling
# --------------------------------------------------------------------------
def test_developer_prompt_states_exact_workspace_path_convention() -> None:
"""Developer prompt gives the exact /data/workspaces/<slug>/<team>/<slug> path."""
prompt = _composed_prompt_for(AgentRole.DEVELOPER, Team.BACKEND)
assert "/data/workspaces/<project-slug>/<team>/<agent-slug>/" in prompt
# Steered to stay inside its own cell workspace.
assert "Stay inside your own cell workspace" in prompt
def test_developer_prompt_forbids_probing_filesystem_for_workspace() -> None:
"""Developer is told not to probe / guess the workspace path."""
prompt = _composed_prompt_for(AgentRole.DEVELOPER, Team.BACKEND)
assert "Do NOT probe for it." in prompt
assert "ls /" in prompt and "find /" in prompt
def test_developer_prompt_gives_sanctioned_secret_path_not_env() -> None:
"""Secrets come via the task description; env/printenv is denied."""
prompt = _composed_prompt_for(AgentRole.DEVELOPER, Team.BACKEND)
# env/printenv called out as denied + bash-guard blocked.
assert "printenv" in prompt
assert "bash-guard" in prompt
# Sanctioned path: value provided in the task description.
assert "in the task description" in prompt
# Escalation route when the value is genuinely missing.
assert "i_am_blocked" in prompt
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/do/* endpoints.
"""Unit tests for /api/v1/do/* endpoints.
Uses a minimal FastAPI test client built from the do router only.
No DB required ContentActions is mocked.
@@ -13,7 +13,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_content_actions
from roboco.api.routes.v2.do import router
from roboco.api.routes.v1.do import router
from roboco.services.gateway.content_actions import ContentActions
_HTTP_200 = 200
@@ -48,7 +48,7 @@ def _build_app(mock_actions: MagicMock) -> FastAPI:
@pytest.mark.asyncio
async def test_commit_descriptive_message_returns_ok() -> None:
"""POST /api/v2/do/commit with a descriptive message returns 200 ok."""
"""POST /api/v1/do/commit with a descriptive message returns 200 ok."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.commit = AsyncMock(
return_value=_make_envelope(status="ok", task_id=_TASK_ID)
@@ -56,7 +56,7 @@ async def test_commit_descriptive_message_returns_ok() -> None:
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/commit",
"/api/v1/do/commit",
json={"message": "add user authentication endpoint"},
headers=_HEADERS,
)
@@ -69,13 +69,13 @@ async def test_commit_descriptive_message_returns_ok() -> None:
@pytest.mark.asyncio
async def test_commit_wip_message_returns_invalid_state() -> None:
"""POST /api/v2/do/commit with 'wip' returns invalid_state envelope."""
"""POST /api/v1/do/commit with 'wip' returns invalid_state envelope."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.commit = AsyncMock(return_value=_make_envelope(status="invalid_state"))
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/commit",
"/api/v1/do/commit",
json={"message": "wip"},
headers=_HEADERS,
)
@@ -88,13 +88,13 @@ async def test_commit_wip_message_returns_invalid_state() -> None:
@pytest.mark.asyncio
async def test_note_reflect_scope_returns_ok() -> None:
"""POST /api/v2/do/note with scope='reflect' returns 200 ok."""
"""POST /api/v1/do/note with scope='reflect' returns 200 ok."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.note = AsyncMock(return_value=_make_envelope(status="noted"))
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/note",
"/api/v1/do/note",
json={"text": "learned how HyDE works", "scope": "reflect"},
headers=_HEADERS,
)
@@ -108,13 +108,13 @@ async def test_note_reflect_scope_returns_ok() -> None:
@pytest.mark.asyncio
async def test_note_garbage_scope_returns_invalid_state() -> None:
"""POST /api/v2/do/note with scope='garbage' returns invalid_state envelope."""
"""POST /api/v1/do/note with scope='garbage' returns invalid_state envelope."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.note = AsyncMock(return_value=_make_envelope(status="invalid_state"))
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/note",
"/api/v1/do/note",
json={"text": "some note", "scope": "garbage"},
headers=_HEADERS,
)
@@ -127,7 +127,7 @@ async def test_note_garbage_scope_returns_invalid_state() -> None:
@pytest.mark.asyncio
async def test_say_with_explicit_task_id_returns_ok() -> None:
"""POST /api/v2/do/say with task_id explicit returns 200 ok."""
"""POST /api/v1/do/say with task_id explicit returns 200 ok."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.say = AsyncMock(
return_value=_make_envelope(status="posted", task_id=_TASK_ID)
@@ -135,7 +135,7 @@ async def test_say_with_explicit_task_id_returns_ok() -> None:
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/say",
"/api/v1/do/say",
json={"channel": "backend-cell", "text": "PR is ready", "task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -149,7 +149,7 @@ async def test_say_with_explicit_task_id_returns_ok() -> None:
@pytest.mark.asyncio
async def test_say_without_task_id_auto_injects() -> None:
"""POST /api/v2/do/say with task_id null passes None; ContentActions injects."""
"""POST /api/v1/do/say with task_id null passes None; ContentActions injects."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.say = AsyncMock(
return_value=_make_envelope(status="posted", task_id=_TASK_ID)
@@ -157,7 +157,7 @@ async def test_say_without_task_id_auto_injects() -> None:
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/say",
"/api/v1/do/say",
json={"channel": "backend-cell", "text": "stand-up update"},
headers=_HEADERS,
)
@@ -171,13 +171,13 @@ async def test_say_without_task_id_auto_injects() -> None:
@pytest.mark.asyncio
async def test_dm_with_no_task_context_returns_invalid_state() -> None:
"""POST /api/v2/do/dm with no task context returns invalid_state envelope."""
"""POST /api/v1/do/dm with no task context returns invalid_state envelope."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.dm = AsyncMock(return_value=_make_envelope(status="invalid_state"))
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/dm",
"/api/v1/do/dm",
json={"recipient": "be-qa-1", "text": "please review"},
headers=_HEADERS,
)
@@ -190,7 +190,7 @@ async def test_dm_with_no_task_context_returns_invalid_state() -> None:
@pytest.mark.asyncio
async def test_evidence_with_task_id_returns_evidence_envelope() -> None:
"""POST /api/v2/do/evidence with task_id returns 200 with evidence in response."""
"""POST /api/v1/do/evidence with task_id returns 200 with evidence in response."""
evidence_payload = {"commits": ["abc123"], "diff_summary": "added 3 files"}
mock_actions = MagicMock(spec=ContentActions)
mock_actions.evidence = AsyncMock(
@@ -201,7 +201,7 @@ async def test_evidence_with_task_id_returns_evidence_envelope() -> None:
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/evidence",
"/api/v1/do/evidence",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -217,14 +217,14 @@ async def test_evidence_with_task_id_returns_evidence_envelope() -> None:
@pytest.mark.asyncio
async def test_notify_dispatches_target_text_priority() -> None:
"""POST /api/v2/do/notify forwards target/text/priority to ContentActions."""
"""POST /api/v1/do/notify forwards target/text/priority to ContentActions."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.notify = AsyncMock(
return_value=_make_envelope(status="ok", task_id=None)
)
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/notify",
"/api/v1/do/notify",
json={"target": "be-pm", "text": "ack me", "priority": "normal"},
headers=_HEADERS,
)
@@ -1,4 +1,4 @@
"""Unit tests for POST /api/v2/do/pr_update — route + schema.
"""Unit tests for POST /api/v1/do/pr_update — route + schema.
Pydantic's model_validator must reject an all-None payload with 422
before ContentActions ever runs; a valid payload must forward title /
@@ -14,7 +14,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_content_actions
from roboco.api.routes.v2.do import router
from roboco.api.routes.v1.do import router
from roboco.services.gateway.content_actions import ContentActions
_HTTP_200 = 200
@@ -49,7 +49,7 @@ async def test_pr_update_all_none_returns_422() -> None:
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/pr_update",
"/api/v1/do/pr_update",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -68,7 +68,7 @@ async def test_pr_update_title_only_forwards_to_content_actions() -> None:
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/pr_update",
"/api/v1/do/pr_update",
json={"task_id": _TASK_ID, "title": "new title"},
headers=_HEADERS,
)
@@ -94,7 +94,7 @@ async def test_pr_update_all_fields_forwarded() -> None:
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v2/do/pr_update",
"/api/v1/do/pr_update",
json={
"task_id": _TASK_ID,
"title": "t",
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/flow/auditor/* endpoints.
"""Unit tests for /api/v1/flow/auditor/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked.
@@ -13,7 +13,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_auditor import router
from roboco.api.routes.v1.flow_auditor import router
_HTTP_200 = 200
@@ -43,7 +43,7 @@ def _build_app(mock_choreographer: MagicMock) -> FastAPI:
@pytest.mark.asyncio
async def test_triage_returns_envelope() -> None:
"""POST /api/v2/flow/auditor/triage returns 200 with envelope shape."""
"""POST /api/v1/flow/auditor/triage returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.auditor_triage = AsyncMock(
return_value=_make_envelope(status="blocked", task_id=_TASK_ID)
@@ -51,7 +51,7 @@ async def test_triage_returns_envelope() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/auditor/triage",
"/api/v1/flow/auditor/triage",
json={},
headers=_HEADERS,
)
@@ -64,13 +64,13 @@ async def test_triage_returns_envelope() -> None:
@pytest.mark.asyncio
async def test_i_am_idle_returns_envelope() -> None:
"""POST /api/v2/flow/auditor/i_am_idle delegates to Choreographer.i_am_idle."""
"""POST /api/v1/flow/auditor/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/auditor/i_am_idle",
"/api/v1/flow/auditor/i_am_idle",
json={},
headers=_HEADERS,
)
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/flow/board/* endpoints.
"""Unit tests for /api/v1/flow/board/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked.
@@ -13,7 +13,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_board import router
from roboco.api.routes.v1.flow_board import router
_HTTP_200 = 200
_HTTP_422 = 422
@@ -44,7 +44,7 @@ def _build_app(mock_choreographer: MagicMock) -> FastAPI:
@pytest.mark.asyncio
async def test_triage_returns_envelope() -> None:
"""POST /api/v2/flow/board/triage returns 200 with envelope shape."""
"""POST /api/v1/flow/board/triage returns 200 with envelope shape."""
mock_chore = MagicMock()
mock_chore.board_triage = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
@@ -52,7 +52,7 @@ async def test_triage_returns_envelope() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/board/triage",
"/api/v1/flow/board/triage",
json={},
headers=_HEADERS,
)
@@ -65,7 +65,7 @@ async def test_triage_returns_envelope() -> None:
@pytest.mark.asyncio
async def test_escalate_to_ceo_returns_envelope() -> None:
"""POST /api/v2/flow/board/escalate_to_ceo forwards task_id and reason."""
"""POST /api/v1/flow/board/escalate_to_ceo forwards task_id and reason."""
mock_chore = MagicMock()
mock_chore.escalate_to_ceo = AsyncMock(
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
@@ -73,7 +73,7 @@ async def test_escalate_to_ceo_returns_envelope() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/board/escalate_to_ceo",
"/api/v1/flow/board/escalate_to_ceo",
json={"task_id": _TASK_ID, "reason": "Strategic call needs CEO sign-off."},
headers=_HEADERS,
)
@@ -93,7 +93,7 @@ def test_escalate_to_ceo_validates_reason_required() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/board/escalate_to_ceo",
"/api/v1/flow/board/escalate_to_ceo",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
@@ -103,13 +103,13 @@ def test_escalate_to_ceo_validates_reason_required() -> None:
@pytest.mark.asyncio
async def test_i_am_idle_returns_envelope() -> None:
"""POST /api/v2/flow/board/i_am_idle delegates to Choreographer.i_am_idle."""
"""POST /api/v1/flow/board/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/board/i_am_idle",
"/api/v1/flow/board/i_am_idle",
json={},
headers=_HEADERS,
)
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/flow/cell_pm/* endpoints.
"""Unit tests for /api/v1/flow/cell_pm/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked.
@@ -13,7 +13,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_cell_pm import router
from roboco.api.routes.v1.flow_cell_pm import router
_HTTP_200 = 200
_HTTP_422 = 422
@@ -44,7 +44,7 @@ def _build_app(mock_choreographer: MagicMock) -> FastAPI:
@pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None:
"""POST /api/v2/flow/cell_pm/give_me_work returns 200 with envelope shape.
"""POST /api/v1/flow/cell_pm/give_me_work returns 200 with envelope shape.
Cell PM's give_me_work routes to ``pm_give_me_work`` so the response
surfaces non-pending PM tasks (paused, awaiting_pm_review) too.
@@ -54,7 +54,7 @@ async def test_give_me_work_returns_envelope() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/give_me_work",
"/api/v1/flow/cell_pm/give_me_work",
json={},
headers=_HEADERS,
)
@@ -67,7 +67,7 @@ async def test_give_me_work_returns_envelope() -> None:
@pytest.mark.asyncio
async def test_triage_returns_envelope() -> None:
"""POST /api/v2/flow/cell_pm/triage returns 200 with task or idle status."""
"""POST /api/v1/flow/cell_pm/triage returns 200 with task or idle status."""
mock_chore = MagicMock()
mock_chore.triage = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
@@ -75,7 +75,7 @@ async def test_triage_returns_envelope() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/triage",
"/api/v1/flow/cell_pm/triage",
json={},
headers=_HEADERS,
)
@@ -88,7 +88,7 @@ async def test_triage_returns_envelope() -> None:
@pytest.mark.asyncio
async def test_unblock_dispatches_task_id_with_restore_true() -> None:
"""POST /api/v2/flow/cell_pm/unblock forwards task_id and restore=True."""
"""POST /api/v1/flow/cell_pm/unblock forwards task_id and restore=True."""
mock_chore = MagicMock()
mock_chore.unblock = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
@@ -96,7 +96,7 @@ async def test_unblock_dispatches_task_id_with_restore_true() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/unblock",
"/api/v1/flow/cell_pm/unblock",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -111,7 +111,7 @@ async def test_unblock_dispatches_task_id_with_restore_true() -> None:
@pytest.mark.asyncio
async def test_unblock_with_restore_false() -> None:
"""POST /api/v2/flow/cell_pm/unblock forwards restore=False when specified."""
"""POST /api/v1/flow/cell_pm/unblock forwards restore=False when specified."""
mock_chore = MagicMock()
mock_chore.unblock = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
@@ -119,7 +119,7 @@ async def test_unblock_with_restore_false() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/unblock",
"/api/v1/flow/cell_pm/unblock",
json={"task_id": _TASK_ID, "restore": False},
headers=_HEADERS,
)
@@ -132,7 +132,7 @@ async def test_unblock_with_restore_false() -> None:
@pytest.mark.asyncio
async def test_complete_dispatches_task_and_notes() -> None:
"""POST /api/v2/flow/cell_pm/complete forwards task_id and notes."""
"""POST /api/v1/flow/cell_pm/complete forwards task_id and notes."""
mock_chore = MagicMock()
mock_chore.complete = AsyncMock(
return_value=_make_envelope(status="completed", task_id=_TASK_ID)
@@ -140,7 +140,7 @@ async def test_complete_dispatches_task_and_notes() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/complete",
"/api/v1/flow/cell_pm/complete",
json={"task_id": _TASK_ID, "notes": "All subtasks done, PR merged."},
headers=_HEADERS,
)
@@ -156,7 +156,7 @@ async def test_complete_dispatches_task_and_notes() -> None:
@pytest.mark.asyncio
async def test_escalate_up_dispatches_reason() -> None:
"""POST /api/v2/flow/cell_pm/escalate_up forwards task_id and reason."""
"""POST /api/v1/flow/cell_pm/escalate_up forwards task_id and reason."""
mock_chore = MagicMock()
mock_chore.escalate_up = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
@@ -164,7 +164,7 @@ async def test_escalate_up_dispatches_reason() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/escalate_up",
"/api/v1/flow/cell_pm/escalate_up",
json={"task_id": _TASK_ID, "reason": "Cross-cell dependency needs Main PM."},
headers=_HEADERS,
)
@@ -177,13 +177,13 @@ async def test_escalate_up_dispatches_reason() -> None:
@pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None:
"""POST /api/v2/flow/cell_pm/i_am_idle delegates to Choreographer.i_am_idle."""
"""POST /api/v1/flow/cell_pm/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/cell_pm/i_am_idle",
"/api/v1/flow/cell_pm/i_am_idle",
json={},
headers=_HEADERS,
)
@@ -200,7 +200,7 @@ def test_complete_rejects_empty_notes() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/complete",
"/api/v1/flow/cell_pm/complete",
json={"task_id": _TASK_ID, "notes": ""},
headers=_HEADERS,
)
@@ -214,7 +214,7 @@ def test_escalate_up_rejects_empty_reason() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/escalate_up",
"/api/v1/flow/cell_pm/escalate_up",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
@@ -224,7 +224,7 @@ def test_escalate_up_rejects_empty_reason() -> None:
@pytest.mark.asyncio
async def test_i_will_plan_dispatches_to_choreographer() -> None:
"""POST /api/v2/flow/cell_pm/i_will_plan forwards task_id and plan."""
"""POST /api/v1/flow/cell_pm/i_will_plan forwards task_id and plan."""
mock_chore = MagicMock()
mock_chore.i_will_plan = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
@@ -232,7 +232,7 @@ async def test_i_will_plan_dispatches_to_choreographer() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/i_will_plan",
"/api/v1/flow/cell_pm/i_will_plan",
json={
"task_id": _TASK_ID,
"plan": "break into 3 subtasks for backend",
@@ -262,7 +262,7 @@ async def test_i_will_plan_dispatches_to_choreographer() -> None:
@pytest.mark.asyncio
async def test_delegate_dispatches_inputs_bundle() -> None:
"""POST /api/v2/flow/cell_pm/delegate forwards body via DelegateInputs."""
"""POST /api/v1/flow/cell_pm/delegate forwards body via DelegateInputs."""
mock_chore = MagicMock()
mock_chore.delegate = AsyncMock(
return_value=_make_envelope(status="created", task_id=_TASK_ID)
@@ -270,7 +270,7 @@ async def test_delegate_dispatches_inputs_bundle() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/delegate",
"/api/v1/flow/cell_pm/delegate",
json={
"parent_task_id": _TASK_ID,
"title": "Implement /v1/foo",
@@ -293,7 +293,7 @@ async def test_delegate_dispatches_inputs_bundle() -> None:
@pytest.mark.asyncio
async def test_submit_up_dispatches_notes() -> None:
"""POST /api/v2/flow/cell_pm/submit_up forwards task_id and notes."""
"""POST /api/v1/flow/cell_pm/submit_up forwards task_id and notes."""
mock_chore = MagicMock()
mock_chore.submit_up = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
@@ -301,7 +301,7 @@ async def test_submit_up_dispatches_notes() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/submit_up",
"/api/v1/flow/cell_pm/submit_up",
json={
"task_id": _TASK_ID,
"notes": "cell finished all subtasks, ready for main pm review",
@@ -314,12 +314,12 @@ async def test_submit_up_dispatches_notes() -> None:
def test_submit_up_rejects_empty_notes() -> None:
"""POST /api/v2/flow/cell_pm/submit_up rejects empty notes."""
"""POST /api/v1/flow/cell_pm/submit_up rejects empty notes."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/submit_up",
"/api/v1/flow/cell_pm/submit_up",
json={"task_id": _TASK_ID, "notes": ""},
headers=_HEADERS,
)
@@ -335,7 +335,7 @@ async def test_unclaim_dispatches() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/unclaim",
"/api/v1/flow/cell_pm/unclaim",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -351,7 +351,7 @@ async def test_resume_dispatches() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/cell_pm/resume",
"/api/v1/flow/cell_pm/resume",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/flow/developer/* endpoints.
"""Unit tests for /api/v1/flow/developer/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked.
@@ -13,7 +13,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_dev import router
from roboco.api.routes.v1.flow_dev import router
_HTTP_200 = 200
_HTTP_422 = 422
@@ -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/developer/give_me_work returns 200 with envelope shape."""
"""POST /api/v1/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/developer/give_me_work",
"/api/v1/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/developer/i_will_work_on forwards task_id and plan."""
"""POST /api/v1/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/developer/i_will_work_on",
"/api/v1/flow/developer/i_will_work_on",
json={"task_id": _TASK_ID, "plan": "implement the feature"},
headers=_HEADERS,
)
@@ -84,7 +84,7 @@ async def test_i_will_work_on_dispatches_task_id() -> None:
@pytest.mark.asyncio
async def test_i_am_done_dispatches_task_and_notes() -> None:
"""POST /api/v2/flow/developer/i_am_done forwards task_id and notes."""
"""POST /api/v1/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)
@@ -92,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/developer/i_am_done",
"/api/v1/flow/developer/i_am_done",
json={"task_id": _TASK_ID, "notes": "all tests pass"},
headers=_HEADERS,
)
@@ -105,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/developer/i_am_blocked forwards task_id and reason."""
"""POST /api/v1/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)
@@ -113,7 +113,7 @@ async def test_i_am_blocked_dispatches_reason() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/developer/i_am_blocked",
"/api/v1/flow/developer/i_am_blocked",
json={"task_id": _TASK_ID, "reason": "waiting for design spec"},
headers=_HEADERS,
)
@@ -125,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/developer/i_am_idle delegates to Choreographer.i_am_idle."""
"""POST /api/v1/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/developer/i_am_idle",
"/api/v1/flow/developer/i_am_idle",
json={},
headers=_HEADERS,
)
@@ -148,7 +148,7 @@ def test_i_am_blocked_rejects_empty_reason() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/developer/i_am_blocked",
"/api/v1/flow/developer/i_am_blocked",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
@@ -165,7 +165,7 @@ async def test_open_pr_dispatches_task_id() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/developer/open_pr",
"/api/v1/flow/developer/open_pr",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -182,7 +182,7 @@ async def test_unclaim_dispatches_task_id() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/developer/unclaim",
"/api/v1/flow/developer/unclaim",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -199,7 +199,7 @@ async def test_resume_dispatches_task_id() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/developer/resume",
"/api/v1/flow/developer/resume",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/flow/documenter/* endpoints.
"""Unit tests for /api/v1/flow/documenter/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked.
@@ -13,7 +13,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_doc import router
from roboco.api.routes.v1.flow_doc import router
_HTTP_200 = 200
_HTTP_422 = 422
@@ -44,13 +44,13 @@ def _build_app(mock_choreographer: MagicMock) -> FastAPI:
@pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None:
"""POST /api/v2/flow/documenter/give_me_work returns 200 with envelope shape."""
"""POST /api/v1/flow/documenter/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/documenter/give_me_work",
"/api/v1/flow/documenter/give_me_work",
json={},
headers=_HEADERS,
)
@@ -63,7 +63,7 @@ async def test_give_me_work_returns_envelope() -> None:
@pytest.mark.asyncio
async def test_claim_doc_task_dispatches_task_id() -> None:
"""POST /api/v2/flow/documenter/claim_doc_task forwards task_id."""
"""POST /api/v1/flow/documenter/claim_doc_task forwards task_id."""
mock_chore = MagicMock()
mock_chore.claim_doc_task = AsyncMock(
return_value=_make_envelope(status="awaiting_documentation", task_id=_TASK_ID)
@@ -71,7 +71,7 @@ async def test_claim_doc_task_dispatches_task_id() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/claim_doc_task",
"/api/v1/flow/documenter/claim_doc_task",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -86,7 +86,7 @@ async def test_claim_doc_task_dispatches_task_id() -> None:
@pytest.mark.asyncio
async def test_i_documented_dispatches_notes_and_files() -> None:
"""POST /api/v2/flow/documenter/i_documented forwards notes and files."""
"""POST /api/v1/flow/documenter/i_documented forwards notes and files."""
mock_chore = MagicMock()
mock_chore.i_documented = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
@@ -94,7 +94,7 @@ async def test_i_documented_dispatches_notes_and_files() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/i_documented",
"/api/v1/flow/documenter/i_documented",
json={
"task_id": _TASK_ID,
"notes": "Documented the auth endpoint in docs/api/auth.md",
@@ -115,13 +115,13 @@ async def test_i_documented_dispatches_notes_and_files() -> None:
@pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None:
"""POST /api/v2/flow/documenter/i_am_idle delegates to Choreographer.i_am_idle."""
"""POST /api/v1/flow/documenter/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/documenter/i_am_idle",
"/api/v1/flow/documenter/i_am_idle",
json={},
headers=_HEADERS,
)
@@ -138,7 +138,7 @@ def test_i_documented_rejects_empty_notes() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/i_documented",
"/api/v1/flow/documenter/i_documented",
json={"task_id": _TASK_ID, "notes": "", "files": ["docs/readme.md"]},
headers=_HEADERS,
)
@@ -152,7 +152,7 @@ def test_i_documented_rejects_empty_files_list() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/i_documented",
"/api/v1/flow/documenter/i_documented",
json={"task_id": _TASK_ID, "notes": "some docs", "files": []},
headers=_HEADERS,
)
@@ -168,7 +168,7 @@ async def test_unclaim_dispatches() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/unclaim",
"/api/v1/flow/documenter/unclaim",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -184,7 +184,7 @@ async def test_resume_dispatches() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/documenter/resume",
"/api/v1/flow/documenter/resume",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/flow/main_pm/* endpoints.
"""Unit tests for /api/v1/flow/main_pm/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked.
@@ -13,7 +13,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_main_pm import router
from roboco.api.routes.v1.flow_main_pm import router
_HTTP_200 = 200
_HTTP_422 = 422
@@ -44,7 +44,7 @@ def _build_app(mock_choreographer: MagicMock) -> FastAPI:
@pytest.mark.asyncio
async def test_triage_all_returns_envelope() -> None:
"""POST /api/v2/flow/main_pm/triage_all returns 200 with task or idle status."""
"""POST /api/v1/flow/main_pm/triage_all returns 200 with task or idle status."""
mock_chore = MagicMock()
mock_chore.triage_all = AsyncMock(
return_value=_make_envelope(status="awaiting_pm_review", task_id=_TASK_ID)
@@ -52,7 +52,7 @@ async def test_triage_all_returns_envelope() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/triage_all",
"/api/v1/flow/main_pm/triage_all",
json={},
headers=_HEADERS,
)
@@ -65,7 +65,7 @@ async def test_triage_all_returns_envelope() -> None:
@pytest.mark.asyncio
async def test_complete_calls_main_pm_complete_directly() -> None:
"""POST /api/v2/flow/main_pm/complete calls main_pm_complete (not dispatch)."""
"""POST /api/v1/flow/main_pm/complete calls main_pm_complete (not dispatch)."""
mock_chore = MagicMock()
mock_chore.main_pm_complete = AsyncMock(
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
@@ -73,7 +73,7 @@ async def test_complete_calls_main_pm_complete_directly() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/complete",
"/api/v1/flow/main_pm/complete",
json={"task_id": _TASK_ID, "notes": "Root task done, escalating to CEO."},
headers=_HEADERS,
)
@@ -89,7 +89,7 @@ async def test_complete_calls_main_pm_complete_directly() -> None:
@pytest.mark.asyncio
async def test_escalate_up_dispatches_reason() -> None:
"""POST /api/v2/flow/main_pm/escalate_up forwards task_id and reason."""
"""POST /api/v1/flow/main_pm/escalate_up forwards task_id and reason."""
mock_chore = MagicMock()
mock_chore.escalate_up = AsyncMock(
return_value=_make_envelope(status="awaiting_ceo_approval", task_id=_TASK_ID)
@@ -97,7 +97,7 @@ async def test_escalate_up_dispatches_reason() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/escalate_up",
"/api/v1/flow/main_pm/escalate_up",
json={"task_id": _TASK_ID, "reason": "Needs CEO sign-off on architecture."},
headers=_HEADERS,
)
@@ -110,7 +110,7 @@ async def test_escalate_up_dispatches_reason() -> None:
@pytest.mark.asyncio
async def test_unblock_dispatches_task_id_with_restore_true() -> None:
"""POST /api/v2/flow/main_pm/unblock forwards task_id with restore=True default."""
"""POST /api/v1/flow/main_pm/unblock forwards task_id with restore=True default."""
mock_chore = MagicMock()
mock_chore.unblock = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
@@ -118,7 +118,7 @@ async def test_unblock_dispatches_task_id_with_restore_true() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/unblock",
"/api/v1/flow/main_pm/unblock",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -131,13 +131,13 @@ async def test_unblock_dispatches_task_id_with_restore_true() -> None:
@pytest.mark.asyncio
async def test_i_am_idle_dispatches_agent_id() -> None:
"""POST /api/v2/flow/main_pm/i_am_idle delegates to Choreographer.i_am_idle."""
"""POST /api/v1/flow/main_pm/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/main_pm/i_am_idle",
"/api/v1/flow/main_pm/i_am_idle",
json={},
headers=_HEADERS,
)
@@ -154,7 +154,7 @@ def test_complete_rejects_empty_notes() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/complete",
"/api/v1/flow/main_pm/complete",
json={"task_id": _TASK_ID, "notes": ""},
headers=_HEADERS,
)
@@ -168,7 +168,7 @@ def test_escalate_up_rejects_empty_reason() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/escalate_up",
"/api/v1/flow/main_pm/escalate_up",
json={"task_id": _TASK_ID, "reason": ""},
headers=_HEADERS,
)
@@ -178,13 +178,13 @@ def test_escalate_up_rejects_empty_reason() -> None:
@pytest.mark.asyncio
async def test_give_me_work_routes_to_pm_give_me_work() -> None:
"""POST /api/v2/flow/main_pm/give_me_work delegates to pm_give_me_work."""
"""POST /api/v1/flow/main_pm/give_me_work delegates to pm_give_me_work."""
mock_chore = MagicMock()
mock_chore.pm_give_me_work = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/give_me_work",
"/api/v1/flow/main_pm/give_me_work",
json={},
headers=_HEADERS,
)
@@ -195,7 +195,7 @@ async def test_give_me_work_routes_to_pm_give_me_work() -> None:
@pytest.mark.asyncio
async def test_i_will_plan_dispatches_to_choreographer() -> None:
"""POST /api/v2/flow/main_pm/i_will_plan forwards task_id and plan."""
"""POST /api/v1/flow/main_pm/i_will_plan forwards task_id and plan."""
mock_chore = MagicMock()
mock_chore.i_will_plan = AsyncMock(
return_value=_make_envelope(status="in_progress", task_id=_TASK_ID)
@@ -203,7 +203,7 @@ async def test_i_will_plan_dispatches_to_choreographer() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/i_will_plan",
"/api/v1/flow/main_pm/i_will_plan",
json={
"task_id": _TASK_ID,
"plan": "split into backend, frontend, ux cells",
@@ -240,7 +240,7 @@ async def test_i_will_plan_dispatches_to_choreographer() -> None:
@pytest.mark.asyncio
async def test_delegate_to_cell_pm_dispatches_inputs_bundle() -> None:
"""POST /api/v2/flow/main_pm/delegate forwards body via DelegateInputs."""
"""POST /api/v1/flow/main_pm/delegate forwards body via DelegateInputs."""
mock_chore = MagicMock()
mock_chore.delegate = AsyncMock(
return_value=_make_envelope(status="created", task_id=_TASK_ID)
@@ -248,7 +248,7 @@ async def test_delegate_to_cell_pm_dispatches_inputs_bundle() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/delegate",
"/api/v1/flow/main_pm/delegate",
json={
"parent_task_id": _TASK_ID,
"title": "Backend slice",
@@ -280,7 +280,7 @@ async def test_escalate_to_ceo_dispatches() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/escalate_to_ceo",
"/api/v1/flow/main_pm/escalate_to_ceo",
json={"task_id": _TASK_ID, "reason": "needs CEO sign-off"},
headers=_HEADERS,
)
@@ -296,7 +296,7 @@ async def test_unclaim_dispatches() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/unclaim",
"/api/v1/flow/main_pm/unclaim",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -312,7 +312,7 @@ async def test_resume_dispatches() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/resume",
"/api/v1/flow/main_pm/resume",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -1,4 +1,4 @@
"""Unit tests for /api/v2/flow/qa/* endpoints.
"""Unit tests for /api/v1/flow/qa/* endpoints.
Uses a minimal FastAPI test client built from the new router only.
No DB required Choreographer is mocked.
@@ -13,7 +13,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_qa import router
from roboco.api.routes.v1.flow_qa import router
_HTTP_200 = 200
_HTTP_422 = 422
@@ -44,13 +44,13 @@ def _build_app(mock_choreographer: MagicMock) -> FastAPI:
@pytest.mark.asyncio
async def test_give_me_work_returns_envelope() -> None:
"""POST /api/v2/flow/qa/give_me_work returns 200 with envelope shape."""
"""POST /api/v1/flow/qa/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/qa/give_me_work",
"/api/v1/flow/qa/give_me_work",
json={},
headers=_HEADERS,
)
@@ -63,7 +63,7 @@ async def test_give_me_work_returns_envelope() -> None:
@pytest.mark.asyncio
async def test_claim_review_dispatches_task_id() -> None:
"""POST /api/v2/flow/qa/claim_review returns 200 with evidence.pr_url in body."""
"""POST /api/v1/flow/qa/claim_review returns 200 with evidence.pr_url in body."""
mock_chore = MagicMock()
mock_chore.claim_review = AsyncMock(
return_value=_make_envelope(
@@ -75,7 +75,7 @@ async def test_claim_review_dispatches_task_id() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/claim_review",
"/api/v1/flow/qa/claim_review",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -90,7 +90,7 @@ async def test_claim_review_dispatches_task_id() -> None:
@pytest.mark.asyncio
async def test_pass_review_with_notes_returns_awaiting_documentation() -> None:
"""POST /api/v2/flow/qa/pass with notes returns 200 with awaiting_documentation."""
"""POST /api/v1/flow/qa/pass with notes returns 200 with awaiting_documentation."""
mock_chore = MagicMock()
mock_chore.pass_review = AsyncMock(
return_value=_make_envelope(status="awaiting_documentation", task_id=_TASK_ID)
@@ -98,7 +98,7 @@ async def test_pass_review_with_notes_returns_awaiting_documentation() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/pass",
"/api/v1/flow/qa/pass",
json={
"task_id": _TASK_ID,
"notes": "All acceptance criteria met, tests green.",
@@ -116,7 +116,7 @@ async def test_pass_review_with_notes_returns_awaiting_documentation() -> None:
@pytest.mark.asyncio
async def test_pass_review_short_notes_returns_tracing_gap_envelope() -> None:
"""POST /api/v2/flow/qa/pass with minimal notes relies on choreographer to gate."""
"""POST /api/v1/flow/qa/pass with minimal notes relies on choreographer to gate."""
mock_chore = MagicMock()
mock_chore.pass_review = AsyncMock(
return_value=_make_envelope(status="tracing_gap", task_id=_TASK_ID)
@@ -124,7 +124,7 @@ async def test_pass_review_short_notes_returns_tracing_gap_envelope() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/pass",
"/api/v1/flow/qa/pass",
json={"task_id": _TASK_ID, "notes": "ok"},
headers=_HEADERS,
)
@@ -137,7 +137,7 @@ async def test_pass_review_short_notes_returns_tracing_gap_envelope() -> None:
@pytest.mark.asyncio
async def test_fail_review_with_issues_returns_needs_revision() -> None:
"""POST /api/v2/flow/qa/fail with issues returns 200 with needs_revision status."""
"""POST /api/v1/flow/qa/fail with issues returns 200 with needs_revision status."""
mock_chore = MagicMock()
mock_chore.fail_review = AsyncMock(
return_value=_make_envelope(status="needs_revision", task_id=_TASK_ID)
@@ -145,7 +145,7 @@ async def test_fail_review_with_issues_returns_needs_revision() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/fail",
"/api/v1/flow/qa/fail",
json={
"task_id": _TASK_ID,
"issues": ["Missing error handling", "No unit tests"],
@@ -162,12 +162,12 @@ async def test_fail_review_with_issues_returns_needs_revision() -> None:
def test_fail_review_rejects_empty_issues_list() -> None:
"""POST /api/v2/flow/qa/fail with empty issues list is rejected with 422."""
"""POST /api/v1/flow/qa/fail with empty issues list is rejected with 422."""
mock_chore = MagicMock()
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/fail",
"/api/v1/flow/qa/fail",
json={"task_id": _TASK_ID, "issues": []},
headers=_HEADERS,
)
@@ -183,7 +183,7 @@ async def test_unclaim_dispatches_task_id() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/unclaim",
"/api/v1/flow/qa/unclaim",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -199,7 +199,7 @@ async def test_resume_dispatches_task_id() -> None:
)
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/resume",
"/api/v1/flow/qa/resume",
json={"task_id": _TASK_ID},
headers=_HEADERS,
)
@@ -213,7 +213,7 @@ async def test_i_am_idle_dispatches_agent_id() -> None:
mock_chore.i_am_idle = AsyncMock(return_value=_make_envelope(status="idle"))
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/qa/i_am_idle",
"/api/v1/flow/qa/i_am_idle",
json={},
headers=_HEADERS,
)
@@ -12,8 +12,8 @@ from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_main_pm import router
from roboco.api.schemas.v2.flow import IWillPlanRequest
from roboco.api.routes.v1.flow_main_pm import router
from roboco.api.schemas.v1.flow import IWillPlanRequest
_AGENT_ID = "00000000-0000-0000-0004-000000000001"
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "main_pm"}
@@ -57,7 +57,7 @@ def test_i_will_plan_rejects_missing_approach() -> None:
"""A PM calling i_will_plan with bare `plan` (no approach) is rejected."""
client = TestClient(_build_app())
resp = client.post(
"/api/v2/flow/main_pm/i_will_plan",
"/api/v1/flow/main_pm/i_will_plan",
headers=_HEADERS,
json={
"task_id": str(uuid4()),
@@ -83,7 +83,7 @@ def test_i_will_plan_rejects_empty_subtasks_for_pm() -> None:
client = TestClient(_build_app(mock_chore))
resp = client.post(
"/api/v2/flow/main_pm/i_will_plan",
"/api/v1/flow/main_pm/i_will_plan",
headers=_HEADERS,
json={
"task_id": str(uuid4()),
@@ -1,6 +1,6 @@
"""Smoke-6: NoteRequest rejects null for decision/reflect string fields.
Original bug: minimax-m2.7 read the MCP tool schema, saw
Original bug: minimax-m3 read the MCP tool schema, saw
`context: anyOf[string, null]`, and decided null was valid. Pydantic
on the route accepted it (because the field WAS `str | None`), passed
it through, and the server-side gate looped on `incomplete_input`.
@@ -15,7 +15,7 @@ from __future__ import annotations
import pytest
from pydantic import ValidationError
from roboco.api.schemas.v2.do import NoteRequest
from roboco.api.schemas.v1.do import NoteRequest
def test_note_request_accepts_omitted_decision_fields() -> None:
@@ -91,3 +91,58 @@ def test_note_request_accepts_non_empty_strings() -> None:
assert req.context == "We have a choice between A and B"
assert req.chosen == "A"
assert req.rationale.startswith("speed")
# ---------------------------------------------------------------------------
# Issue #15: list-typed fields tolerate a lone scalar (string or, for
# options, a dict). Pre-coercion these 422'd at the route and the agent's
# retry loop tripped the do-server circuit breaker.
# ---------------------------------------------------------------------------
def test_note_request_coerces_string_consequences_to_list() -> None:
"""A single string for consequences is wrapped into a one-element list."""
req = NoteRequest.model_validate(
{"text": "x", "scope": "decision", "consequences": "we lose durability"}
)
assert req.consequences == ["we lose durability"]
def test_note_request_coerces_string_next_steps_to_list() -> None:
"""A single string for next_steps is wrapped into a one-element list."""
req = NoteRequest.model_validate(
{"text": "x", "scope": "reflect", "next_steps": "wait for QA"}
)
assert req.next_steps == ["wait for QA"]
def test_note_request_coerces_single_option_dict_to_list() -> None:
"""A single option dict (not wrapped in a list) is wrapped into a list."""
req = NoteRequest.model_validate(
{
"text": "x",
"scope": "decision",
"options": {"name": "redis", "pros": "fast", "cons": "ephemeral"},
}
)
assert req.options == [{"name": "redis", "pros": "fast", "cons": "ephemeral"}]
def test_note_request_list_fields_pass_through_unchanged() -> None:
"""Already-list values are not re-wrapped."""
req = NoteRequest.model_validate(
{
"text": "x",
"scope": "reflect",
"next_steps": ["step one", "step two"],
}
)
assert req.next_steps == ["step one", "step two"]
def test_note_request_list_fields_accept_none() -> None:
"""Omitted list fields stay None (default), not coerced."""
req = NoteRequest.model_validate({"text": "x", "scope": "note"})
assert req.consequences is None
assert req.next_steps is None
assert req.options is None
+4 -4
View File
@@ -63,13 +63,13 @@ def test_create_app_registers_all_router_prefixes() -> None:
)
def test_create_app_includes_v2_flow_routes() -> None:
"""API v2 (intent-verb) routers from `routes/v2/*` are mounted."""
def test_create_app_includes_v1_flow_routes() -> None:
"""API v1 (intent-verb) routers from `routes/v1/*` are mounted."""
app = create_app()
paths = {r.path for r in app.routes} # type: ignore[attr-defined]
# v2 routers register their own prefixes; we just confirm /api/v2 paths
# v1 routers register their own prefixes; we just confirm /api/v1 paths
# exist after include_router.
assert any(p.startswith("/api/v2") for p in paths)
assert any(p.startswith("/api/v1") for p in paths)
def test_create_app_attaches_cors_middleware() -> None:
+4 -4
View File
@@ -13,7 +13,7 @@ These tests pin the contract:
1. Envelope holds an optional ``correlation_id`` and round-trips it via
``as_dict()``.
2. The v2 flow route reads ``request.state.correlation_id`` (set by
2. The v1 flow route reads ``request.state.correlation_id`` (set by
``CorrelationIdMiddleware``) and stamps it onto the envelope before
returning.
3. Both MCP shims attach an ``X-Correlation-ID`` header on every POST,
@@ -40,7 +40,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.middleware import CorrelationIdMiddleware
from roboco.api.routes.v2.flow_dev import router as flow_dev_router
from roboco.api.routes.v1.flow_dev import router as flow_dev_router
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.envelope import Envelope
@@ -98,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/developer/give_me_work",
"/api/v1/flow/developer/give_me_work",
json={},
headers={**_DEV_AGENT_HEADERS, "X-Correlation-ID": "trace-xyz"},
)
@@ -112,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/developer/give_me_work",
"/api/v1/flow/developer/give_me_work",
json={},
headers=_DEV_AGENT_HEADERS,
)
@@ -6,7 +6,7 @@ from uuid import uuid4
import pytest
from pydantic import ValidationError
from roboco.api.schemas.v2.flow import DelegateRequest
from roboco.api.schemas.v1.flow import DelegateRequest
def _ok_payload() -> dict:
+1
View File
@@ -273,6 +273,7 @@ def _stub_task(*, with_project: bool = False) -> SimpleNamespace:
nature=TaskNature.TECHNICAL,
task_type=TaskType.CODE,
project_id=uuid4(),
product_id=None,
project=(SimpleNamespace(slug="proj-1") if with_project else None),
docs_complete=False,
pr_created=False,
@@ -1,4 +1,4 @@
"""Schema-level tests for v2 flow request bodies."""
"""Schema-level tests for v1 flow request bodies."""
from __future__ import annotations
@@ -6,7 +6,7 @@ from uuid import uuid4
import pytest
from pydantic import ValidationError
from roboco.api.schemas.v2.flow import DelegateRequest
from roboco.api.schemas.v1.flow import DelegateRequest
def test_delegate_request_requires_task_type() -> None:
@@ -1,6 +1,6 @@
"""v2 flow routes reject requests with the wrong X-Agent-Role.
"""v1 flow routes reject requests with the wrong X-Agent-Role.
Defense-in-depth check: every v2 flow router declares router-level
Defense-in-depth check: every v1 flow router declares router-level
dependencies that 403 if `X-Agent-Role` doesn't match the router's role.
We verify that gate by mounting only the dev router on a minimal app
with the choreographer mocked no DB / lifespan needed because the
@@ -14,7 +14,7 @@ from unittest.mock import AsyncMock, MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from roboco.api.deps import get_choreographer
from roboco.api.routes.v2.flow_dev import router as flow_dev_router
from roboco.api.routes.v1.flow_dev import router as flow_dev_router
_HTTP_200 = 200
_HTTP_403 = 403
@@ -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/developer/give_me_work",
"/api/v1/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/developer/give_me_work",
"/api/v1/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/developer/give_me_work",
"/api/v1/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/developer/give_me_work",
"/api/v1/flow/developer/give_me_work",
json={},
headers={"X-Agent-ID": "00000000-0000-0000-0000-000000000001"},
)
+17 -1
View File
@@ -104,7 +104,7 @@ def test_validate_git_no_context_passes() -> None:
def test_git_doc_to_pm_review_requires_docs_complete() -> None:
ctx = GitContext(docs_complete=False, pr_created=True)
with pytest.raises(GitRequirementError, match="docs_complete"):
with pytest.raises(GitRequirementError, match="documentation not yet complete"):
validate_git_requirements("awaiting_documentation", "awaiting_pm_review", ctx)
@@ -147,6 +147,22 @@ def test_git_claimed_to_in_progress_succeeds_with_branch() -> None:
assert validate_git_requirements("claimed", "in_progress", ctx) is True
def test_git_claimed_to_in_progress_coordination_task_needs_no_branch() -> None:
# A coordination/fan-out task (product, no repo of its own) does no git and
# never gets a branch — it must reach in_progress so Main PM can delegate.
# Without this exemption, start() raised GitRequirementError and the whole
# board->cells fan-out deadlocked (the PM looped on i_will_plan).
ctx = GitContext(branch_name=None, is_coordination=True)
assert validate_git_requirements("claimed", "in_progress", ctx) is True
def test_git_claimed_to_in_progress_still_blocks_branchless_code_task() -> None:
# A normal code task with no branch is still blocked (regression guard).
ctx = GitContext(branch_name=None, is_coordination=False)
with pytest.raises(GitRequirementError, match="no branch"):
validate_git_requirements("claimed", "in_progress", ctx)
# ---------------------------------------------------------------------------
# check_parallel_completion
# ---------------------------------------------------------------------------
@@ -600,13 +600,18 @@ async def test_delegate_unknown_role_rejected() -> None:
@pytest.mark.asyncio
async def test_delegate_parent_no_project_rejected() -> None:
"""Line 1306: parent.project_id is None → invalid_state."""
"""A parent with NEITHER a project_id NOR a product_id → invalid_state.
(A parent with a product_id but no project is allowed — subtasks resolve a
repo from the product map — so the guard now requires both to be None.)
"""
pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
status="in_progress",
assigned_to=pm_id,
project_id=None,
product_id=None,
title="p",
)
task_svc = AsyncMock()
@@ -629,7 +634,7 @@ async def test_delegate_parent_no_project_rejected() -> None:
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "no project_id" in body["message"]
assert "project_id" in body["message"] and "product_id" in body["message"]
# ---------------------------------------------------------------------------
@@ -1325,3 +1330,38 @@ async def test_i_will_work_on_claimed_with_no_plan_accepts_recovery_plan() -> No
body = env.as_dict()
assert body["error"] is None, f"expected success, got {body}"
task_svc.set_plan.assert_awaited_once()
# ---------------------------------------------------------------------------
# _pending_assignment_guard: board/advisory roles can idle without claiming
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize("role", ["product_owner", "head_marketing", "auditor"])
async def test_pending_assignment_guard_exempts_board_roles(role: str) -> None:
"""A board/advisory agent that reviewed a still-pending coordination task
has no i_will_work_on/i_will_plan verb, so the idle gate must let it pass."""
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [
MagicMock(id=uuid4(), status="pending")
]
task_svc.agent_for.return_value = MagicMock(role=role, team=None, slug=None)
c = Choreographer(_make_deps(task=task_svc))
assert await c._pending_assignment_guard(uuid4(), {}) is None
@pytest.mark.asyncio
async def test_pending_assignment_guard_still_blocks_developer() -> None:
"""A developer holding a pending unclaimed task is still told to claim it."""
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [
MagicMock(id=uuid4(), status="pending")
]
task_svc.agent_for.return_value = MagicMock(
role="developer", team="backend", slug=None
)
c = Choreographer(_make_deps(task=task_svc))
guard = await c._pending_assignment_guard(uuid4(), {})
assert guard is not None
assert guard.as_dict()["error"] == "invalid_state"
+105 -46
View File
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
@@ -270,8 +271,13 @@ async def test_note_reflect_scope_succeeds() -> None:
@pytest.mark.asyncio
async def test_note_reflect_missing_required_fields_returns_incomplete_input() -> None:
"""Pre-gateway parity: reflect without structured fields fails fast."""
async def test_note_reflect_missing_fields_records_with_placeholder() -> None:
"""Issue #15: a thin reflect note is recorded, not rejected.
Missing narrative fields are defaulted to a visible placeholder so the
entry still lands (audit value preserved) and the do-server circuit
breaker never fires on a well-intentioned note.
"""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
@@ -292,15 +298,21 @@ async def test_note_reflect_missing_required_fields_returns_incomplete_input() -
)
body = env.as_dict()
assert body["error"] == "incomplete_input"
missing = set(body["missing"])
assert {"what_done", "what_learned", "what_struggled"}.issubset(missing)
journal_svc.write_entry.assert_not_awaited()
assert body["error"] is None
assert body["status"] == "noted"
journal_svc.write_entry.assert_awaited_once()
content = journal_svc.write_entry.call_args.kwargs["content"]
# Missing what_done/what_learned/what_struggled render as the placeholder.
assert "(not provided)" in content
@pytest.mark.asyncio
async def test_note_decision_requires_options_and_more() -> None:
"""Pre-gateway parity: decision requires context/options(>=2)/chosen/rationale."""
async def test_note_decision_thin_payload_records_not_rejected() -> None:
"""Issue #15: decision with missing/thin fields is recorded, not rejected.
A single option is kept as-is (the min-2 gate no longer hard-blocks);
missing context/chosen/rationale default to a placeholder.
"""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
@@ -313,7 +325,7 @@ async def test_note_decision_requires_options_and_more() -> None:
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
# Missing everything structured → incomplete_input listing all required.
# Bare decision: no structured fields → still recorded with placeholders.
env = await ca.note(
agent_id=agent_id,
text="bare decision",
@@ -321,10 +333,10 @@ async def test_note_decision_requires_options_and_more() -> None:
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "incomplete_input"
assert {"context", "options", "chosen", "rationale"}.issubset(set(body["missing"]))
assert body["error"] is None
assert body["status"] == "noted"
# Single option still fails (min 2).
# Single option no longer fails — recorded as-is.
env = await ca.note(
agent_id=agent_id,
text="decision with one option",
@@ -338,10 +350,10 @@ async def test_note_decision_requires_options_and_more() -> None:
},
)
body = env.as_dict()
assert body["error"] == "incomplete_input"
assert "options" in body["missing"]
assert body["error"] is None
assert body["status"] == "noted"
# Two options + all required → success.
# Fully-filled decision still succeeds (no regression).
env = await ca.note(
agent_id=agent_id,
text="real decision",
@@ -361,26 +373,48 @@ async def test_note_decision_requires_options_and_more() -> None:
assert body["error"] is None
assert body["status"] == "noted"
# Three+ options also pass — 2 is the floor, not the ceiling.
@pytest.mark.asyncio
async def test_note_decision_scalar_list_fields_are_coerced() -> None:
"""Issue #15: lone-scalar options/consequences are wrapped into lists.
An agent that passes a single option dict or a single consequences
string must not be rejected — the value is wrapped into a one-element
list and rendered into the entry.
"""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
task_svc.get.return_value = MagicMock(
id=task_id, assigned_to=agent_id, status="in_progress"
)
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="three-way decision",
text="decision with scalar fields",
scope="decision",
task_id=task_id,
structured={
"context": "queue tech choice",
"options": [
{"name": "redis", "pros": "fast", "cons": "ephemeral"},
{"name": "postgres", "pros": "durable", "cons": "slower"},
{"name": "rabbitmq", "pros": "ordered", "cons": "ops overhead"},
],
"chosen": "rabbitmq",
"rationale": "ordering matters more than raw speed here",
"context": "queue tech",
# single dict instead of a list
"options": {"name": "redis", "pros": "fast", "cons": "ephemeral"},
"chosen": "redis",
"rationale": "speed",
# single string instead of a list
"consequences": "we lose durability across restarts",
},
)
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "noted"
content = journal_svc.write_entry.call_args.kwargs["content"]
assert "redis" in content
assert "we lose durability across restarts" in content
@pytest.mark.asyncio
@@ -659,17 +693,19 @@ async def test_verify_explicit_task_ownership_returns_not_found() -> None:
# ---------------------------------------------------------------------------
# B4 — decision/reflect remediate includes a literal call example
# Issue #15 — thin decision/reflect notes are recorded, never rejected
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_decision_incomplete_input_includes_call_example() -> None:
"""Decision rejection includes a literal note(scope='decision', ...) template."""
async def test_decision_thin_note_records_without_rejection() -> None:
"""A bare decision (no structured fields) is recorded, not rejected."""
task = AsyncMock()
task.get_active_task_for_agent.return_value = None
task.get_journal_context_task_for_agent.return_value = None
task.agent_for.return_value = MagicMock(role="cell_pm")
deps = _make_deps(task=task)
journal_svc = AsyncMock()
deps = _make_deps(task=task, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
@@ -678,23 +714,20 @@ async def test_decision_incomplete_input_includes_call_example() -> None:
scope="decision",
)
body = env.as_dict()
assert body["error"] == "incomplete_input"
remediate = body.get("remediate", "")
# Must include a literal call template, not just a field list
assert "note(scope='decision'" in remediate, remediate
assert "context=" in remediate, remediate
assert "options=[" in remediate, remediate
assert "chosen=" in remediate, remediate
assert "rationale=" in remediate, remediate
assert body["error"] is None
assert body["status"] == "noted"
journal_svc.write_entry.assert_awaited_once()
@pytest.mark.asyncio
async def test_reflect_incomplete_input_includes_call_example() -> None:
"""Reflect rejection includes a literal note(scope='reflect', ...) call template."""
async def test_reflect_thin_note_records_without_rejection() -> None:
"""A bare reflect (no structured fields) is recorded, not rejected."""
task = AsyncMock()
task.get_active_task_for_agent.return_value = None
task.get_journal_context_task_for_agent.return_value = None
task.agent_for.return_value = MagicMock(role="developer")
deps = _make_deps(task=task)
journal_svc = AsyncMock()
deps = _make_deps(task=task, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
@@ -703,12 +736,9 @@ async def test_reflect_incomplete_input_includes_call_example() -> None:
scope="reflect",
)
body = env.as_dict()
assert body["error"] == "incomplete_input"
remediate = body.get("remediate", "")
assert "note(scope='reflect'" in remediate, remediate
assert "what_done=" in remediate, remediate
assert "what_learned=" in remediate, remediate
assert "what_struggled=" in remediate, remediate
assert body["error"] is None
assert body["status"] == "noted"
journal_svc.write_entry.assert_awaited_once()
# ---------------------------------------------------------------------------
@@ -798,3 +828,32 @@ async def test_progress_ownership_enforced() -> None:
env = await actions.progress(agent_id=agent_id, task_id=t.id, message="x")
assert env.as_dict()["error"] is not None
task.record_plan_progress.assert_not_awaited()
@pytest.mark.asyncio
async def test_commit_gate_reads_settings_min_chars(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The commit gate reads settings.commit_subject_min_chars."""
monkeypatch.setattr(settings, "commit_subject_min_chars", 500)
ca = ContentActions(_make_deps())
# Normally a fine descriptive subject, now shorter than the bumped minimum.
env = await ca.commit(
agent_id=uuid4(),
message="feat(api): add /healthz endpoint for liveness checks",
)
assert env.as_dict()["error"] == "invalid_state"
@pytest.mark.asyncio
async def test_commit_gate_reads_settings_banned_words(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The commit gate reads settings.commit_banned_words."""
monkeypatch.setattr(settings, "commit_banned_words", ("bananaword",))
monkeypatch.setattr(settings, "commit_subject_min_chars", 1)
ca = ContentActions(_make_deps())
env = await ca.commit(agent_id=uuid4(), message="bananaword")
assert env.as_dict()["error"] == "invalid_state"
@@ -336,6 +336,110 @@ async def test_evidence_blocks_when_not_assignee() -> None:
git_svc.diff.assert_not_awaited()
# ---------------------------------------------------------------------------
# Board co-review exemption (cluster C5): a board role may record its review
# note/say on a board/coordination task held by the OTHER board member.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_board_role_may_note_coordination_task_held_by_other_board() -> None:
"""A board/coordination task (project_id=None, product_id set) is reviewed
by BOTH board members; the non-assignee board reviewer may still note it."""
agent_id = uuid4()
other_board_id = uuid4()
task_id = uuid4()
coord_task = MagicMock(
id=task_id,
status="pending",
assigned_to=other_board_id,
project_id=None,
product_id=uuid4(),
)
task_svc = AsyncMock()
task_svc.get.return_value = coord_task
task_svc.get_active_task_for_agent.return_value = None
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
# _make_deps defaults agent_for to a developer; override AFTER so the
# board co-review exemption sees a board role.
task_svc.agent_for.return_value = MagicMock(role="head_marketing")
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="UX + positioning review of the board task",
scope="note",
task_id=task_id,
)
assert env.error is None
journal_svc.write_entry.assert_awaited_once()
@pytest.mark.asyncio
async def test_board_role_blocked_on_project_task_held_by_other() -> None:
"""The exemption is narrow: a board role still cannot post to a normal
project-backed task assigned to someone else."""
agent_id = uuid4()
other_id = uuid4()
task_id = uuid4()
project_task = MagicMock(
id=task_id,
status="in_progress",
assigned_to=other_id,
project_id=uuid4(),
product_id=None,
)
task_svc = AsyncMock()
task_svc.get.return_value = project_task
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
# Board role, but a project-backed task — exemption must NOT apply.
task_svc.agent_for.return_value = MagicMock(role="product_owner")
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="trying to note a code task I don't own",
scope="note",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "not_authorized"
journal_svc.write_entry.assert_not_awaited()
@pytest.mark.asyncio
async def test_non_board_role_blocked_on_coordination_task_held_by_other() -> None:
"""The exemption is board-only: a developer cannot piggyback on it."""
agent_id = uuid4()
other_id = uuid4()
task_id = uuid4()
coord_task = MagicMock(
id=task_id,
status="pending",
assigned_to=other_id,
project_id=None,
product_id=uuid4(),
)
task_svc = AsyncMock()
task_svc.get.return_value = coord_task
task_svc.agent_for.return_value = MagicMock(role="developer")
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="dev trying to note a coordination task",
scope="note",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "not_authorized"
journal_svc.write_entry.assert_not_awaited()
@pytest.mark.asyncio
async def test_evidence_unassigned_task_allows_inspection() -> None:
"""A task with assigned_to=None (between handoffs) may be inspected.
@@ -0,0 +1,196 @@
"""#7: assignee-vs-task_type matrix for delegate().
Cell-PM delegation friction: agents burned turns probing the
``_validate_assignee_task_type`` guard by trial and error. Two behaviors
are pinned here:
1. The UX/UI cell's developers ARE its designers — ``task_type='design'``
is legitimate work for ``ux-dev-1``/``ux-dev-2`` (Role.DEVELOPER on
Team.UX_UI), so delegate must accept it. Backend/frontend devs are NOT
design assignees and stay rejected.
2. The rejection ``remediate`` is per-assignee-class: a developer mis-type
gets a developer hint (not the generic 'pass planning to a Cell PM').
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.choreographer._impl import DelegateInputs
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
"messaging": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
for m 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, m).return_value = []
_ldef = base["journal"].latest_decision_at.return_value
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
return ChoreographerDeps(**base)
def _parent(parent_id: object, team: str) -> MagicMock:
return MagicMock(
id=parent_id,
project_id=uuid4(),
team=team,
status="in_progress",
task_type="planning",
sequence=0,
assigned_to=uuid4(),
)
def _inputs(assigned_to: str, team: str, task_type: str) -> DelegateInputs:
return DelegateInputs(
title="Cell work item",
description="A real unit of work for the cell to deliver end to end",
acceptance_criteria=["the deliverable exists", "it is linked to a PR"],
assigned_to=assigned_to,
team=team,
task_type=task_type,
nature="technical",
estimated_complexity="medium",
)
# --- Behavior 1: validator matrix (pure function, no DB) ----------------
# The static-guard rejection for documentation lives in
# `_delegate_static_guards` (Task #163), NOT in `_validate_assignee_task_type`
# — the validator allows `documentation` for any dev role. So
# `documentation` is exercised separately in the static-guard tests below.
_CELL_PM_PLANNING_HINT = "delegating to a Cell PM"
@pytest.mark.parametrize(
("assigned_to", "task_type", "allowed"),
[
# UX devs are designers — design is legitimate.
("ux-dev-1", "design", True),
("ux-dev-2", "design", True),
("ux-dev-1", "code", True),
("ux-dev-1", "research", True),
# Backend/frontend devs are NOT design assignees.
("be-dev-1", "design", False),
("fe-dev-2", "design", False),
# Code is fine for any dev.
("be-dev-1", "code", True),
("fe-dev-1", "research", True),
],
)
def test_validate_assignee_task_type_matrix(
assigned_to: str, task_type: str, allowed: bool
) -> None:
err = Choreographer._validate_assignee_task_type(assigned_to, task_type)
if allowed:
assert err is None, f"{assigned_to}/{task_type} should be allowed, got {err!r}"
else:
assert err is not None, f"{assigned_to}/{task_type} should be rejected"
assert assigned_to in err and task_type in err
def test_remediate_for_ux_dev_mentions_design() -> None:
hint = Choreographer._assignee_task_type_remediate("ux-dev-1")
assert "design" in hint.lower()
# Not the generic Cell-PM planning hint.
assert _CELL_PM_PLANNING_HINT not in hint
def test_remediate_for_backend_dev_routes_design_to_ux() -> None:
hint = Choreographer._assignee_task_type_remediate("be-dev-1")
assert "ux" in hint.lower()
assert "code" in hint.lower()
# Not the generic Cell-PM planning hint.
assert _CELL_PM_PLANNING_HINT not in hint
def test_remediate_for_cell_pm_keeps_planning_hint() -> None:
hint = Choreographer._assignee_task_type_remediate("be-pm")
assert _CELL_PM_PLANNING_HINT in hint
# --- Behavior 2: static-guard envelope wiring ---------------------------
@pytest.mark.asyncio
async def test_static_guards_allow_ux_design_subtask() -> None:
"""ux-pm delegating a design subtask to its designer passes the guard."""
pm_id = uuid4()
parent_id = uuid4()
c = Choreographer(_make_deps())
env = await c._delegate_static_guards(
pm_id,
parent_id,
_parent(parent_id, "ux_ui"),
_inputs("ux-dev-1", "ux_ui", "design"),
)
assert env is None, f"UX design subtask must pass static guards, got {env}"
@pytest.mark.asyncio
async def test_static_guards_reject_backend_design_with_dev_remediate() -> None:
"""be-pm handing a design subtask to a backend dev is rejected, and the
remediate is the developer-class hint (not the planning/Cell-PM hint)."""
pm_id = uuid4()
parent_id = uuid4()
c = Choreographer(_make_deps())
env = await c._delegate_static_guards(
pm_id,
parent_id,
_parent(parent_id, "backend"),
_inputs("be-dev-1", "backend", "design"),
)
assert env is not None, "design subtask for a backend dev must be rejected"
body = env.as_dict()
assert body["error"] == "invalid_state", body
remediate = body["remediate"] or ""
assert "UX" in remediate
# The developer-class hint, NOT the generic Cell-PM planning hint.
assert _CELL_PM_PLANNING_HINT not in remediate
@pytest.mark.asyncio
async def test_static_guards_reject_documentation_for_ux_dev() -> None:
"""documentation stays non-delegatable even for a UX dev (Task #163):
the lifecycle auto-creates the doc phase after the code subtask."""
pm_id = uuid4()
parent_id = uuid4()
c = Choreographer(_make_deps())
env = await c._delegate_static_guards(
pm_id,
parent_id,
_parent(parent_id, "ux_ui"),
_inputs("ux-dev-1", "ux_ui", "documentation"),
)
assert env is not None, "documentation subtask must be rejected"
body = env.as_dict()
assert body["error"] == "invalid_state", body
assert "not PM-" in (body["message"] or "") or "documenter" in (
body["remediate"] or ""
)
@@ -0,0 +1,125 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import (
Choreographer,
ChoreographerDeps,
DelegateInputs,
)
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
for m 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, m).return_value = []
_ldef = base["journal"].latest_decision_at.return_value
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
return ChoreographerDeps(**base)
def _parent(pm_id, product_id=None, project_id=None):
return MagicMock(
id=uuid4(),
project_id=project_id or uuid4(),
product_id=product_id,
status="in_progress",
assigned_to=pm_id,
)
def _inputs(**kw: Any) -> DelegateInputs:
base: dict[str, Any] = {
"title": "Implement endpoint",
"description": "Add /v1/foo endpoint with tests",
"assigned_to": "be-dev-1",
"team": "backend",
"task_type": "code",
"nature": "technical",
"acceptance_criteria": ["GET /v1/foo returns 200 with body"],
}
base.update(kw)
return DelegateInputs(**base)
async def _run(parent, inputs, product=None):
pm_id = parent.assigned_to
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.get_subtasks.return_value = []
task_svc.create_subtask.return_value = MagicMock(id=uuid4())
deps = _make_deps(task=task_svc, **({"product": product} if product else {}))
c = Choreographer(deps)
env = await c.delegate(pm_id, parent.id, inputs)
return env, task_svc
@pytest.mark.asyncio
async def test_explicit_project_id_overrides_everything() -> None:
override = uuid4()
parent = _parent(uuid4(), product_id=uuid4())
env, task_svc = await _run(parent, _inputs(project_id=override))
assert env.error is None, env.as_dict()
req = task_svc.create_subtask.call_args.args[0]
assert req.project_id == override
@pytest.mark.asyncio
async def test_product_map_resolves_project_when_no_override() -> None:
mapped = uuid4()
product_id = uuid4()
parent = _parent(uuid4(), product_id=product_id)
product = AsyncMock()
product.project_for.return_value = mapped
env, task_svc = await _run(parent, _inputs(), product=product)
assert env.error is None, env.as_dict()
req = task_svc.create_subtask.call_args.args[0]
assert req.project_id == mapped
assert req.product_id == product_id # inherited onto the subtask
product.project_for.assert_awaited_once()
@pytest.mark.asyncio
async def test_falls_back_to_parent_project_when_no_product() -> None:
parent = _parent(uuid4(), product_id=None)
env, task_svc = await _run(parent, _inputs())
assert env.error is None, env.as_dict()
req = task_svc.create_subtask.call_args.args[0]
assert req.project_id == parent.project_id
@pytest.mark.asyncio
async def test_partial_product_map_degrades_to_parent_project() -> None:
product_id = uuid4()
parent = _parent(uuid4(), product_id=product_id)
product = AsyncMock()
product.project_for.return_value = None # no mapping for this cell
env, task_svc = await _run(parent, _inputs(), product=product)
assert env.error is None, env.as_dict()
req = task_svc.create_subtask.call_args.args[0]
assert req.project_id == parent.project_id
assert req.product_id == product_id
+22 -2
View File
@@ -80,14 +80,34 @@ class TestResolveParentBranch:
task_service.get.assert_not_called()
@pytest.mark.asyncio
async def test_falls_back_when_parent_has_no_branch(self) -> None:
async def test_branchless_parent_uses_project_default_branch(self) -> None:
# #17: a branchless coordination parent never gets a branch. The child
# was cut from its own project's default branch, so that is the real
# merge target — NOT a string-derived ref the parent never created
# (which would have no valid merge target and wedge the cell↔Main-PM
# loop).
task = MagicMock(
parent_task_id=uuid4(),
branch_name="feature/backend/ROOT0001--CELL0001",
)
task_service = AsyncMock()
task_service.get = AsyncMock(return_value=MagicMock(branch_name=None))
# Parent exists but has no branch yet → string derivation.
task_service.project_default_branch_for_task = AsyncMock(return_value="master")
result = await resolve_parent_branch(task, task_service)
assert result == "master"
task_service.project_default_branch_for_task.assert_awaited_once_with(task)
@pytest.mark.asyncio
async def test_branchless_parent_falls_back_to_string_when_no_project(self) -> None:
# No project to consult (resolver returns None) → string derivation
# remains the last-resort fallback.
task = MagicMock(
parent_task_id=uuid4(),
branch_name="feature/backend/ROOT0001--CELL0001",
)
task_service = AsyncMock()
task_service.get = AsyncMock(return_value=MagicMock(branch_name=None))
task_service.project_default_branch_for_task = AsyncMock(return_value=None)
result = await resolve_parent_branch(task, task_service)
assert result == "feature/backend/ROOT0001"
-15
View File
@@ -3,21 +3,12 @@
from __future__ import annotations
from roboco.services.gateway.remediation import (
hint_for_missing_plan,
hint_for_missing_progress,
hint_for_missing_reflect,
hint_for_unaddressed_acceptance_criteria,
hint_for_unread_a2a,
)
def test_missing_plan_hint() -> None:
h = hint_for_missing_plan(task_id="abc-123")
assert "i_will_work_on" in h
assert "abc-123" in h
assert "plan=" in h
def test_missing_progress_hint() -> None:
h = hint_for_missing_progress()
assert "commit" in h.lower() or "progress" in h.lower()
@@ -36,9 +27,3 @@ def test_unaddressed_criteria_hint() -> None:
assert "criterion 1" in h
assert "criterion 3" in h
assert "t-1" in h
def test_unread_a2a_hint() -> None:
h = hint_for_unread_a2a(count=2, task_id="t-1")
assert "2" in h
assert "t-1" in h
+9
View File
@@ -67,6 +67,15 @@ class TestRoleConfigCatalog:
assert "ToolSearch" not in cfg.flow_tools
assert "ToolSearch" not in cfg.do_tools
def test_every_role_has_evidence(self) -> None:
# Issue #8: a developer container shipped without
# mcp__roboco-do__evidence. `evidence` is a read-only inspection
# tool every role needs (devs read their own PR diff, QA/PM review,
# the auditor inspects). Lock the invariant so no role's do-tool
# tuple can silently drop it again.
for role, cfg in ROLE_CONFIGS.items():
assert "evidence" in cfg.do_tools, f"{role} missing evidence do-tool"
def test_dev_flow_matches_spec_intents_for_role() -> None:
"""role_config._DEV_FLOW must equal spec.intents_for_role(Role.DEVELOPER)."""
+2 -2
View File
@@ -56,7 +56,7 @@ def test_commit_posts_message_and_files(do_module): # type: ignore[no-untyped-d
assert result["status"] == "in_progress"
args, kwargs = fake_client.post.call_args
assert "/api/v2/do/commit" in args[0]
assert "/api/v1/do/commit" in args[0]
assert kwargs["json"] == {"message": "feat(api): add /healthz", "files": ["foo.py"]}
@@ -136,5 +136,5 @@ def test_evidence_posts_task_id(do_module): # type: ignore[no-untyped-def]
do_module.evidence("task-uuid")
args, kwargs = fake_client.post.call_args
assert "/api/v2/do/evidence" in args[0]
assert "/api/v1/do/evidence" in args[0]
assert kwargs["json"] == {"task_id": "task-uuid"}
+19 -19
View File
@@ -111,12 +111,12 @@ def _reload_for_role(
def test_role_path_uses_agent_role(flow_module: types.ModuleType) -> None:
expected = "/api/v2/flow/developer/give_me_work"
expected = "/api/v1/flow/developer/give_me_work"
assert flow_module._role_path("give_me_work") == expected
def test_role_path_includes_verb(flow_module: types.ModuleType) -> None:
assert flow_module._role_path("i_am_done") == "/api/v2/flow/developer/i_am_done"
assert flow_module._role_path("i_am_done") == "/api/v1/flow/developer/i_am_done"
def test_give_me_work_posts_to_orchestrator(flow_module: types.ModuleType) -> None:
@@ -128,7 +128,7 @@ def test_give_me_work_posts_to_orchestrator(flow_module: types.ModuleType) -> No
assert result == {"status": "idle", "task_id": None}
fake_client.post.assert_called_once()
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/developer/give_me_work" in args[0]
assert "/api/v1/flow/developer/give_me_work" in args[0]
assert kwargs["headers"]["X-Agent-ID"] == "00000000-0000-0000-0000-000000000001"
assert kwargs["headers"]["X-Agent-Role"] == "developer"
@@ -148,7 +148,7 @@ def test_i_will_work_on_passes_plan(flow_module: types.ModuleType) -> None:
"risks": [],
"open_questions": [],
}
assert "/api/v2/flow/developer/i_will_work_on" in args[0]
assert "/api/v1/flow/developer/i_will_work_on" in args[0]
def test_i_will_work_on_plan_defaults_to_none(flow_module: types.ModuleType) -> None:
@@ -200,7 +200,7 @@ def test_i_am_done_sends_task_id_and_notes(flow_module: types.ModuleType) -> Non
assert result == {"status": "awaiting_qa"}
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/developer/i_am_done" in args[0]
assert "/api/v1/flow/developer/i_am_done" in args[0]
assert kwargs["json"] == {"task_id": "task-abc", "notes": "all tests green"}
@@ -243,11 +243,11 @@ def test_i_am_idle_posts_empty_body(flow_module: types.ModuleType) -> None:
assert result == {"status": "idle"}
_, kwargs = fake_client.post.call_args
assert kwargs["json"] == {}
assert "/api/v2/flow/developer/i_am_idle" in fake_client.post.call_args[0][0]
assert "/api/v1/flow/developer/i_am_idle" in fake_client.post.call_args[0][0]
def test_claim_review_posts_to_qa_path(monkeypatch: pytest.MonkeyPatch) -> None:
"""When AGENT_ROLE=qa, claim_review forwards to /api/v2/flow/qa/claim_review."""
"""When AGENT_ROLE=qa, claim_review forwards to /api/v1/flow/qa/claim_review."""
srv = _reload_for_role(monkeypatch, "qa", "00000000-0000-0000-0000-000000000002")
fake_client = _make_fake_client({"status": "claimed", "evidence": {}})
@@ -257,7 +257,7 @@ def test_claim_review_posts_to_qa_path(monkeypatch: pytest.MonkeyPatch) -> None:
assert result["status"] == "claimed"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/qa/claim_review" in args[0]
assert "/api/v1/flow/qa/claim_review" in args[0]
assert kwargs["json"] == {"task_id": "task-uuid"}
@@ -271,7 +271,7 @@ def test_pass_review_passes_notes(monkeypatch: pytest.MonkeyPatch) -> None:
srv.pass_review("task-uuid", notes)
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/qa/pass" in args[0]
assert "/api/v1/flow/qa/pass" in args[0]
assert kwargs["json"] == {"task_id": "task-uuid", "notes": notes}
@@ -285,7 +285,7 @@ def test_fail_review_passes_issues_list(monkeypatch: pytest.MonkeyPatch) -> None
srv.fail_review("task-uuid", issues)
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/qa/fail" in args[0]
assert "/api/v1/flow/qa/fail" in args[0]
assert kwargs["json"] == {"task_id": "task-uuid", "issues": issues}
@@ -304,7 +304,7 @@ def test_claim_doc_task_posts_to_documenter_path(
assert result["status"] == "claimed"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/documenter/claim_doc_task" in args[0]
assert "/api/v1/flow/documenter/claim_doc_task" in args[0]
assert kwargs["json"] == {"task_id": "task-uuid"}
@@ -320,7 +320,7 @@ def test_i_documented_passes_notes_and_files(monkeypatch: pytest.MonkeyPatch) ->
assert result["status"] == "awaiting_pm_review"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/documenter/i_documented" in args[0]
assert "/api/v1/flow/documenter/i_documented" in args[0]
assert kwargs["json"] == {
"task_id": "task-uuid",
"notes": "wrote docs/foo.md",
@@ -340,7 +340,7 @@ def test_triage_uses_role_path(monkeypatch: pytest.MonkeyPatch) -> None:
assert result["status"] == "blocked"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/cell_pm/triage" in args[0]
assert "/api/v1/flow/cell_pm/triage" in args[0]
assert kwargs["json"] == {}
@@ -356,7 +356,7 @@ def test_triage_all_uses_role_path(monkeypatch: pytest.MonkeyPatch) -> None:
assert result["status"] == "idle"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/main_pm/triage_all" in args[0]
assert "/api/v1/flow/main_pm/triage_all" in args[0]
assert kwargs["json"] == {}
@@ -372,7 +372,7 @@ def test_unblock_with_restore_true(monkeypatch: pytest.MonkeyPatch) -> None:
assert result["status"] == "in_progress"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/cell_pm/unblock" in args[0]
assert "/api/v1/flow/cell_pm/unblock" in args[0]
assert kwargs["json"] == {"task_id": "task-uuid", "restore": True}
@@ -403,7 +403,7 @@ def test_complete_passes_notes(monkeypatch: pytest.MonkeyPatch) -> None:
assert result["status"] == "completed"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/cell_pm/complete" in args[0]
assert "/api/v1/flow/cell_pm/complete" in args[0]
assert kwargs["json"] == {"task_id": "task-uuid", "notes": "approved"}
@@ -419,7 +419,7 @@ def test_escalate_up_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
assert result["status"] == "blocked"
args, kwargs = fake_client.post.call_args
assert "/api/v2/flow/cell_pm/escalate_up" in args[0]
assert "/api/v1/flow/cell_pm/escalate_up" in args[0]
assert kwargs["json"] == {
"task_id": "task-uuid",
"reason": "cross-cell help needed",
@@ -427,7 +427,7 @@ def test_escalate_up_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
def test_escalate_to_ceo_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
"""Board / Main PM verb forwards to /api/v2/flow/<role>/escalate_to_ceo."""
"""Board / Main PM verb forwards to /api/v1/flow/<role>/escalate_to_ceo."""
srv = _reload_for_role(
monkeypatch, "product_owner", "00000000-0000-0000-0000-000000000005"
)
@@ -441,7 +441,7 @@ def test_escalate_to_ceo_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
args, kwargs = fake_client.post.call_args
# 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 "/api/v1/flow/board/escalate_to_ceo" in args[0]
assert kwargs["json"] == {
"task_id": "task-uuid",
"reason": "strategic decision needed",
@@ -6,7 +6,7 @@ the *missing wiring*: rejection envelopes from the gateway must be
forwarded to the SDK, and when the breaker opens the rejection must be
substituted with the circuit_open envelope before reaching the agent.
The tests stub the orchestrator's httpx.Client (path '/api/v2/flow/...')
The tests stub the orchestrator's httpx.Client (path '/api/v1/flow/...')
and the SDK's httpx.Client (path '/verb/attempted') so the helper can
be exercised end-to-end without a real network. We pick which mock to
return by inspecting the URL the code under test is hitting.
@@ -392,11 +392,11 @@ def test_sdk_returns_malformed_json_fails_open(flow_module: types.ModuleType) ->
def test_verb_from_path_extracts_last_segment(flow_module: types.ModuleType) -> None:
"""_verb_from_path strips the role prefix and returns the verb token."""
assert (
flow_module._verb_from_path("/api/v2/flow/developer/i_am_done") == "i_am_done"
flow_module._verb_from_path("/api/v1/flow/developer/i_am_done") == "i_am_done"
)
assert flow_module._verb_from_path("/api/v2/flow/qa/pass") == "pass"
assert flow_module._verb_from_path("/api/v1/flow/qa/pass") == "pass"
assert (
flow_module._verb_from_path("/api/v2/flow/board/escalate_to_ceo")
flow_module._verb_from_path("/api/v1/flow/board/escalate_to_ceo")
== "escalate_to_ceo"
)
@@ -99,7 +99,7 @@ def test_intent_public_mapping_used_on_unknowns(
def test_post_to_correct_orchestrator_path(
flow_module_qa: types.ModuleType,
) -> None:
"""Calling the registered 'pass' tool POSTs to /api/v2/flow/qa/pass."""
"""Calling the registered 'pass' tool POSTs to /api/v1/flow/qa/pass."""
captured: list[tuple[str, dict]] = []
def _client_factory(*_a: object, **_kw: object) -> MagicMock:
@@ -126,7 +126,7 @@ def test_post_to_correct_orchestrator_path(
orchestrator_calls = [(u, b) for u, b in captured if "test-orchestrator" in u]
assert len(orchestrator_calls) == 1
url, body = orchestrator_calls[0]
assert url.endswith("/api/v2/flow/qa/pass"), (
assert url.endswith("/api/v1/flow/qa/pass"), (
f"pass_review must POST to /qa/pass, got {url}"
)
assert body == {"task_id": "task-id-123", "notes": "LGTM"}
-72
View File
@@ -20,12 +20,6 @@ from roboco.models.a2a import (
)
from roboco.models.agents import AgentConfig
from roboco.models.base import ModelProvider
from roboco.models.channel import (
create_announcements_channel,
create_cell_channel,
create_cross_cell_channel,
)
from roboco.models.handoff import HandoffParams, create_handoff
from roboco.models.llm_catalog import (
MODEL_CATALOG,
_build_anthropic_entries,
@@ -154,46 +148,6 @@ def test_build_role_levels_skips_invalid_level() -> None:
assert result == {}
# ---------------------------------------------------------------------------
# Channel factories — create_cell_channel, create_cross_cell_channel,
# create_announcements_channel (channel.py 98, 116-117, 135)
# ---------------------------------------------------------------------------
def test_create_cell_channel() -> None:
members = [uuid4(), uuid4()]
auditor_id = uuid4()
ch = create_cell_channel("backend", members, auditor_id)
assert ch.name == "#backend-cell"
assert ch.slug == "backend-cell"
assert ch.members == members
assert ch.silent_observers == [auditor_id]
def test_create_cross_cell_channel() -> None:
members = [uuid4(), uuid4()]
main_pm = uuid4()
auditor = uuid4()
ch = create_cross_cell_channel("dev-all", members, main_pm, auditor)
assert ch.name == "#dev-all"
# main_pm joins the member list.
assert main_pm in ch.members
assert auditor in ch.silent_observers
def test_create_announcements_channel() -> None:
agents = [uuid4() for _ in range(3)]
board = [uuid4(), uuid4()]
main_pm = uuid4()
auditor = uuid4()
ch = create_announcements_channel(agents, board, main_pm, auditor)
assert ch.slug == "announcements"
# Writers = board + main_pm.
assert main_pm in ch.writers
for b in board:
assert b in ch.writers
# ---------------------------------------------------------------------------
# A2A helpers — A2AConversation methods + a2a_state_to_task_status fallback
# ---------------------------------------------------------------------------
@@ -282,32 +236,6 @@ def test_a2a_conversation_status_values() -> None:
assert A2AConversationStatus.CLOSED == "closed"
# ---------------------------------------------------------------------------
# Handoff factory (models/handoff.py 202-208)
# ---------------------------------------------------------------------------
def test_create_handoff_includes_changelog_required_doc() -> None:
task_id = uuid4()
handoff = create_handoff(
HandoffParams(
task_id=task_id,
summary="Built feature X",
commits=[{"sha": "abc123", "message": "init"}],
dev_notes_location="/notes/x",
new_functionality=["new login"],
modified_behavior=["redirect"],
breaking_changes=["remove old endpoint"],
)
)
assert handoff.task_id == task_id
assert handoff.summary == "Built feature X"
# required_docs always includes a changelog item.
doc_types = [d.doc_type for d in handoff.required_docs]
assert "changelog" in doc_types
assert handoff.new_functionality == ["new login"]
# ---------------------------------------------------------------------------
# Sanity: ModelProvider import is reachable
# ---------------------------------------------------------------------------
@@ -1,152 +0,0 @@
"""Notification model factory function coverage."""
from __future__ import annotations
from uuid import uuid4
from roboco.models import NotificationPriority, NotificationType
from roboco.models.notification import (
create_alert,
create_blocker_escalation,
create_broadcast,
create_documentation_request,
create_priority_change,
create_review_request,
create_task_assignment,
)
def test_create_task_assignment() -> None:
pm_id = uuid4()
agent_id = uuid4()
task_id = uuid4()
n = create_task_assignment(
from_pm=pm_id,
to_agent=agent_id,
task_id=task_id,
task_title="Build feature X",
)
assert n.type == NotificationType.TASK_ASSIGNMENT
assert n.from_agent == pm_id
assert n.to_agents == [agent_id]
assert n.related_task_id == task_id
assert "Build feature X" in n.subject
def test_create_blocker_escalation() -> None:
from_pm = uuid4()
to_pm = uuid4()
task_id = uuid4()
n = create_blocker_escalation(
from_pm=from_pm,
to_pm=to_pm,
task_id=task_id,
blocker_description="DB unreachable",
)
assert n.type == NotificationType.BLOCKER_ESCALATION
assert n.priority == NotificationPriority.HIGH
assert n.body == "DB unreachable"
assert n.related_task_id == task_id
def test_create_review_request() -> None:
from_pm = uuid4()
to_qa = uuid4()
task_id = uuid4()
n = create_review_request(
from_pm=from_pm,
to_qa=to_qa,
task_id=task_id,
task_title="Refactor login",
)
assert n.type == NotificationType.REVIEW_REQUEST
assert "Refactor login" in n.subject
assert n.to_agents == [to_qa]
def test_create_documentation_request() -> None:
from_pm = uuid4()
to_doc = uuid4()
task_id = uuid4()
n = create_documentation_request(
from_pm=from_pm,
to_documenter=to_doc,
task_id=task_id,
task_title="API endpoint",
)
assert n.type == NotificationType.DOCUMENTATION_REQUEST
assert "API endpoint" in n.subject
assert "needs documentation" in n.body
def test_create_priority_change_p0_marks_urgent() -> None:
sender = uuid4()
recipients = [uuid4()]
task_id = uuid4()
n = create_priority_change(
from_agent=sender,
to_agents=recipients,
task_id=task_id,
task_title="Critical task",
new_priority=0,
)
assert n.type == NotificationType.PRIORITY_CHANGE
assert n.priority == NotificationPriority.URGENT
assert "P0" in n.body
def test_create_priority_change_high_label() -> None:
sender = uuid4()
recipients = [uuid4()]
task_id = uuid4()
n = create_priority_change(
from_agent=sender,
to_agents=recipients,
task_id=task_id,
task_title="P1 task",
new_priority=1,
)
assert n.priority == NotificationPriority.HIGH
assert "P1" in n.body
def test_create_priority_change_unknown_priority_uses_fallback_label() -> None:
sender = uuid4()
recipients = [uuid4()]
task_id = uuid4()
n = create_priority_change(
from_agent=sender,
to_agents=recipients,
task_id=task_id,
task_title="Custom",
new_priority=99,
)
# Body falls through to f"P{new_priority}".
assert "P99" in n.body
def test_create_alert() -> None:
sender = uuid4()
recipients = [uuid4(), uuid4()]
n = create_alert(
from_agent=sender,
to_agents=recipients,
subject="System down",
body="rebooting now",
)
assert n.type == NotificationType.ALERT
assert n.priority == NotificationPriority.URGENT
assert n.subject == "System down"
def test_create_broadcast_does_not_require_ack() -> None:
sender = uuid4()
recipients = [uuid4()]
n = create_broadcast(
from_agent=sender,
to_agents=recipients,
subject="All hands",
body="please review",
)
assert n.type == NotificationType.BROADCAST
assert n.requires_ack is False
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from uuid import uuid4
import pytest
from pydantic import ValidationError
from roboco.foundation.identity import Team
from roboco.models.product import ProductCellMapping, ProductCreate
def test_product_create_requires_slug_pattern() -> None:
with pytest.raises(ValidationError):
ProductCreate(name="X", slug="Has Spaces")
def test_cell_mapping_accepts_cell_team() -> None:
m = ProductCellMapping(team=Team.BACKEND, project_id=uuid4())
assert m.team is Team.BACKEND
def test_cell_mapping_rejects_non_cell_team() -> None:
with pytest.raises(ValidationError):
ProductCellMapping(team=Team.BOARD, project_id=uuid4())
@@ -0,0 +1,293 @@
"""Dispatch routing for blocked tasks (#17) and agentless claims (#19).
#17: a blocked task reassigned to Main PM must dispatch THAT assignee to
unblock it, not the ex-assignee cell PM the pre-unblock note is assignee-only
and the ex-assignee got not_authorized, livelocking the respawn.
#19: a task left claimed/in_progress with an assignee but no running container
is invisibly stuck (only PENDING tasks get fresh dispatch). The orchestrator
must (re)spawn the assignee after a short grace window, or release the claim to
pending when the assignee is unknown.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.models.runtime import AgentInstance
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
from roboco.seeds.initial_data import AGENT_UUIDS
def _orch() -> AgentOrchestrator:
orch = object.__new__(AgentOrchestrator)
orch._instances = {}
return orch
def _active_instance(agent_id: str) -> AgentInstance:
return AgentInstance(agent_id=agent_id, state=AgentState.ACTIVE)
# ---------------------------------------------------------------------------
# _blocker_resolver_slug (#17)
# ---------------------------------------------------------------------------
def test_blocked_task_assigned_to_main_pm_dispatches_main_pm() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"team": "backend",
"assigned_to": AGENT_UUIDS["main-pm"],
}
# The current assignee (Main PM) holds unblock authority — dispatch THEM,
# not the ex-assignee cell PM (be-pm), which would loop on not_authorized.
assert orch._blocker_resolver_slug(task) == "main-pm"
def test_blocked_task_assigned_to_board_dispatches_board() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"team": "backend",
"assigned_to": AGENT_UUIDS["product-owner"],
}
assert orch._blocker_resolver_slug(task) == "product-owner"
def test_blocked_task_held_by_dev_falls_back_to_cell_pm() -> None:
orch = _orch()
# A dev raised i_am_blocked and still holds the task → cell PM resolves.
task: dict[str, Any] = {
"id": "t1",
"team": "backend",
"assigned_to": AGENT_UUIDS["be-dev-1"],
}
assert orch._blocker_resolver_slug(task) == "be-pm"
def test_blocked_task_unassigned_falls_back_to_cell_pm() -> None:
orch = _orch()
task: dict[str, Any] = {"id": "t1", "team": "frontend", "assigned_to": None}
assert orch._blocker_resolver_slug(task) == "fe-pm"
def test_blocked_task_non_cell_team_unassigned_is_unroutable() -> None:
orch = _orch()
task: dict[str, Any] = {"id": "t1", "team": "board", "assigned_to": None}
assert orch._blocker_resolver_slug(task) is None
# ---------------------------------------------------------------------------
# _claimed_task_needs_agent (#19)
# ---------------------------------------------------------------------------
_STALE = (datetime.now(UTC) - timedelta(minutes=30)).isoformat()
_FRESH = datetime.now(UTC).isoformat()
def test_claimed_task_with_no_agent_past_grace_returns_assignee() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "claimed",
"assigned_to": AGENT_UUIDS["be-dev-1"],
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) == "be-dev-1"
def test_claimed_task_with_active_agent_is_healthy() -> None:
orch = _orch()
orch._instances["be-dev-1"] = _active_instance("be-dev-1")
task: dict[str, Any] = {
"id": "t1",
"status": "claimed",
"assigned_to": AGENT_UUIDS["be-dev-1"],
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) is None
def test_claimed_task_within_grace_window_is_skipped() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "claimed",
"assigned_to": AGENT_UUIDS["be-dev-1"],
"updated_at": _FRESH,
}
# Fresh claim — spawn may still be in flight; do not churn.
assert orch._claimed_task_needs_agent(task) is None
def test_claimed_task_without_assignee_is_skipped() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "claimed",
"assigned_to": None,
"claimed_by": None,
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) is None
def test_hitl_blocked_claimed_task_is_skipped() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "blocked",
"blocker_resolver_type": "human",
"assigned_to": AGENT_UUIDS["be-dev-1"],
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) is None
def test_in_progress_task_with_no_agent_returns_assignee() -> None:
orch = _orch()
task: dict[str, Any] = {
"id": "t1",
"status": "in_progress",
"assigned_to": AGENT_UUIDS["fe-dev-2"],
"updated_at": _STALE,
}
assert orch._claimed_task_needs_agent(task) == "fe-dev-2"
# ---------------------------------------------------------------------------
# _get_prompt_for_agent — role-appropriate respawn prompt (#19)
# ---------------------------------------------------------------------------
#
# A respawn must hand each role the prompt it can act on. The bug: the PM/board
# branch fell through to the developer prompt, telling a PM/board agent to write
# code and call verbs it does not own.
def _task(**over: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "t1",
"title": "T",
"status": "in_progress",
"team": "backend",
}
base.update(over)
return base
@pytest.mark.parametrize(
("agent_slug", "marker"),
[
("be-dev-1", "development task"),
("be-qa", "ready for QA review"),
("be-doc", "ready for documentation"),
("be-pm", "PM for backend team"),
("main-pm", "MAIN PM at RoboCo"),
("product-owner", "You are on the Board"),
("auditor", "AUDIT"),
],
)
def test_get_prompt_for_agent_routes_by_role(agent_slug: str, marker: str) -> None:
orch = _orch()
prompt = orch._get_prompt_for_agent(agent_slug, _task())
assert marker in prompt
def test_get_prompt_for_pm_is_not_the_dev_prompt() -> None:
# Regression for #19: a respawned PM must NOT receive the developer prompt.
orch = _orch()
pm_prompt = orch._get_prompt_for_agent("be-pm", _task())
assert "development task" not in pm_prompt
assert "You do NOT code" in pm_prompt
def test_get_prompt_for_board_is_not_the_dev_prompt() -> None:
orch = _orch()
board_prompt = orch._get_prompt_for_agent("product-owner", _task())
assert "development task" not in board_prompt
assert "do NOT build, code" in board_prompt
def test_head_marketing_prompt_is_marketing_on_marketing_team() -> None:
orch = _orch()
prompt = orch._get_prompt_for_agent("head-marketing", _task(team="marketing"))
assert "marketing task" in prompt
def test_head_marketing_prompt_is_board_off_marketing_team() -> None:
orch = _orch()
prompt = orch._get_prompt_for_agent("head-marketing", _task(team="backend"))
assert "You are on the Board" in prompt
# ---------------------------------------------------------------------------
# _dispatch_claimed_without_agent — one-spawn-per-tick throttle (#19)
# ---------------------------------------------------------------------------
#
# `monkeypatch.setattr` is used to stub instance methods because direct
# attribute assignment (`orch.spawn_agent = ...`) trips mypy's method-assign
# check; the fixture is the type-safe, suppression-free way to do it.
def _stub_git_context(orch: AgentOrchestrator, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(orch, "_task_git_context", lambda _task: None)
@pytest.mark.asyncio
async def test_dispatch_claimed_without_agent_spawns_at_most_one_per_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = _orch()
orch._tick_handled_tasks = set()
stale_tasks = [
{"id": f"t{i}", "status": "claimed", "assigned_to": AGENT_UUIDS["be-dev-1"]}
for i in range(3)
]
monkeypatch.setattr(orch, "_fetch_tasks", AsyncMock(return_value=stale_tasks))
monkeypatch.setattr(orch, "_claimed_task_needs_agent", lambda _task: "be-dev-1")
_stub_git_context(orch, monkeypatch)
spawn = AsyncMock()
monkeypatch.setattr(orch, "spawn_agent", spawn)
await orch._dispatch_claimed_without_agent(client=MagicMock())
# Three agentless claims, but only ONE container spawned this tick.
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_dispatch_claimed_without_agent_releases_unknown_without_spending_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The release-to-pending path spawns nothing and must NOT consume the
# per-tick spawn budget — it keeps draining stale unknown claims, then
# spawns the first task with a known assignee.
orch = _orch()
orch._tick_handled_tasks = set()
tasks = [
{"id": "u1", "status": "claimed", "assigned_to": "ghost-uuid"},
{"id": "u2", "status": "claimed", "assigned_to": "ghost-uuid"},
{"id": "k1", "status": "claimed", "assigned_to": AGENT_UUIDS["be-dev-1"]},
]
monkeypatch.setattr(orch, "_fetch_tasks", AsyncMock(return_value=tasks))
def _needs(task: dict[str, Any]) -> str:
return orch._resolve_agent_slug(str(task["assigned_to"]))
monkeypatch.setattr(orch, "_claimed_task_needs_agent", _needs)
_stub_git_context(orch, monkeypatch)
release = AsyncMock()
monkeypatch.setattr(orch, "_release_claim_to_pending", release)
spawn = AsyncMock()
monkeypatch.setattr(orch, "spawn_agent", spawn)
await orch._dispatch_claimed_without_agent(client=MagicMock())
expected_releases = 2 # both ghost claims released
assert release.await_count == expected_releases
spawn.assert_awaited_once() # then one known assignee respawned
+203 -19
View File
@@ -1,11 +1,13 @@
"""Board agents (Product Owner / Head of Marketing) must be dispatched for
assigned board-team tasks and only ONCE.
"""Board agents (Product Owner + Head of Marketing) review board-team tasks.
Before this, no dispatcher spawned board roles (only PMs, devs, QA, doc, and
marketing were wired), so a task assigned to the Product Owner sat `pending`
forever. Board roles also have no verb to claim/plan/delegate/complete, so a
respawn cannot advance the task dispatch is one-shot per (agent, task); the
CEO reassigns to Main PM after the board review is recorded.
Cluster C5:
- A board/coordination task is a TWO-reviewer gate: BOTH the Product Owner and
the Head of Marketing must review it before it reaches the CEO (finding #4).
Each reviewer is dispatched ONCE board roles have no verb to claim/plan/
delegate/complete, so a respawn cannot advance the task and would just loop.
- Once BOTH reviewers have finished, the orchestrator emits exactly ONE formal
CEO notification so the handoff to Approve & Start is an actionable signal,
not buried channel chatter (finding #2).
"""
from __future__ import annotations
@@ -22,6 +24,7 @@ def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._instances = {}
orch._board_dispatched = set()
orch._board_review_ceo_notified = set()
return orch
@@ -37,34 +40,75 @@ def _board_task(assigned_to: str) -> dict[str, Any]:
@pytest.mark.asyncio
async def test_board_agent_spawned_once_for_assigned_board_task() -> None:
async def test_both_board_agents_dispatched_for_board_task() -> None:
"""A board task must dispatch BOTH the PO and the Head of Marketing — the
review is a two-reviewer gate, not a single-assignee claim (finding #4)."""
orch = _make_orch()
task = _board_task("product-owner")
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_maybe_notify_ceo_board_review_complete",
new=AsyncMock(),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._handle_board_assigned_task(task, "product-owner")
# Second tick: task is still pending (board has no progression verb) —
# must NOT respawn (no loop).
await orch._handle_board_assigned_task(task, "product-owner")
spawn.assert_awaited_once()
assert spawn.await_args.kwargs["agent_id"] == "product-owner"
assert spawn.await_args.kwargs["task_id"] == task["id"]
dispatched = {call.kwargs["agent_id"] for call in spawn.await_args_list}
assert dispatched == {"product-owner", "head-marketing"}
for call in spawn.await_args_list:
assert call.kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_board_handler_skips_when_agent_active() -> None:
async def test_each_board_agent_spawned_only_once() -> None:
"""Board roles have no progression verb — a re-tick must NOT respawn."""
orch = _make_orch()
task = _board_task("head-marketing")
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_maybe_notify_ceo_board_review_complete",
new=AsyncMock(),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._handle_board_assigned_task(task, "head-marketing")
spawn.assert_not_awaited()
# Second tick: still pending — must not respawn either reviewer.
await orch._handle_board_assigned_task(task, "head-marketing")
dispatched = [call.kwargs["agent_id"] for call in spawn.await_args_list]
assert sorted(dispatched) == ["head-marketing", "product-owner"]
@pytest.mark.asyncio
async def test_board_handler_skips_active_reviewer_but_dispatches_other() -> None:
"""An already-running reviewer is skipped; the other is still dispatched."""
orch = _make_orch()
task = _board_task("product-owner")
def _active(slug: str) -> bool:
return slug == "product-owner"
with (
patch.object(orch, "_is_agent_active", side_effect=_active),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_maybe_notify_ceo_board_review_complete",
new=AsyncMock(),
),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._handle_board_assigned_task(task, "product-owner")
dispatched = {call.kwargs["agent_id"] for call in spawn.await_args_list}
assert dispatched == {"head-marketing"}
@pytest.mark.asyncio
@@ -79,12 +123,152 @@ async def test_board_handler_ignores_non_board_assignee() -> None:
spawn.assert_not_awaited()
def test_board_review_prompt_uses_board_verbs_only() -> None:
@pytest.mark.asyncio
async def test_unassigned_board_task_dispatches_both_via_board_handler() -> None:
"""An UNASSIGNED board task must route through the board handler so BOTH
reviewers are dispatched not claimed + single-spawned for the PO only
(finding #4). The task stays unclaimed for the CEO's Approve & Start."""
orch = _make_orch()
task = {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"task_type": "code",
"title": "Strategic feature",
"description": "A board-level task to review and shape.",
"assigned_to": None,
}
client = object()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch,
"_maybe_notify_ceo_board_review_complete",
new=AsyncMock(),
),
patch.object(
orch, "_claim_task_for_agent", new=AsyncMock(return_value=True)
) as claim,
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._route_unassigned_pm_task(client, task)
# Board work is a two-reviewer gate, not a claim — never claimed here.
claim.assert_not_awaited()
dispatched = {call.kwargs["agent_id"] for call in spawn.await_args_list}
assert dispatched == {"product-owner", "head-marketing"}
def test_board_review_complete_requires_both_reviewers_idle() -> None:
"""Review is complete only when BOTH reviewers are dispatched AND idle."""
orch = _make_orch()
task_id = str(uuid4())
with patch.object(orch, "_is_agent_active", return_value=False):
# Neither dispatched yet.
assert orch._board_review_complete(task_id) is False
# Only PO dispatched.
orch._board_dispatched.add(("product-owner", task_id))
assert orch._board_review_complete(task_id) is False
# Both dispatched and idle.
orch._board_dispatched.add(("head-marketing", task_id))
assert orch._board_review_complete(task_id) is True
def test_board_review_not_complete_while_a_reviewer_active() -> None:
orch = _make_orch()
task_id = str(uuid4())
orch._board_dispatched.add(("product-owner", task_id))
orch._board_dispatched.add(("head-marketing", task_id))
with patch.object(
orch, "_is_agent_active", side_effect=lambda s: s == "head-marketing"
):
# HoM still running its review — not done yet.
assert orch._board_review_complete(task_id) is False
@pytest.mark.asyncio
async def test_ceo_notified_once_when_board_review_complete() -> None:
"""Finding #2: a formal CEO notification fires exactly once when both
board reviewers have finished."""
orch = _make_orch()
task_id = str(uuid4())
orch._board_dispatched.add(("product-owner", task_id))
orch._board_dispatched.add(("head-marketing", task_id))
svc = AsyncMock()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch(
"roboco.services.notification.NotificationService",
return_value=svc,
),
):
await orch._maybe_notify_ceo_board_review_complete(task_id)
# Second tick: already notified — must not re-emit.
await orch._maybe_notify_ceo_board_review_complete(task_id)
svc.send_board_review_complete_notification.assert_awaited_once_with(
task_id=task_id
)
assert task_id in orch._board_review_ceo_notified
@pytest.mark.asyncio
async def test_ceo_not_notified_while_review_incomplete() -> None:
"""No CEO notification until BOTH reviewers are done."""
orch = _make_orch()
task_id = str(uuid4())
# Only PO has been dispatched/finished.
orch._board_dispatched.add(("product-owner", task_id))
svc = AsyncMock()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch(
"roboco.services.notification.NotificationService",
return_value=svc,
),
):
await orch._maybe_notify_ceo_board_review_complete(task_id)
svc.send_board_review_complete_notification.assert_not_awaited()
assert task_id not in orch._board_review_ceo_notified
@pytest.mark.asyncio
async def test_ceo_notify_failure_allows_retry() -> None:
"""A notification failure clears the one-shot guard so a later tick retries."""
orch = _make_orch()
task_id = str(uuid4())
orch._board_dispatched.add(("product-owner", task_id))
orch._board_dispatched.add(("head-marketing", task_id))
svc = AsyncMock()
svc.send_board_review_complete_notification.side_effect = RuntimeError("db down")
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch(
"roboco.services.notification.NotificationService",
return_value=svc,
),
):
await orch._maybe_notify_ceo_board_review_complete(task_id)
# Guard cleared so a later, healthy tick can re-emit.
assert task_id not in orch._board_review_ceo_notified
def test_board_review_prompt_names_both_reviewers_and_board_verbs() -> None:
"""The prompt must steer board agents to their real verbs (triage / note /
say / i_am_idle) and away from claim/plan/delegate they do not have."""
say / i_am_idle), make the PO+HoM pair-review model explicit, and away from
claim/plan/delegate they do not have."""
orch = _make_orch()
prompt = orch._build_board_prompt(_board_task("product-owner"))
assert "triage()" in prompt
assert "note(" in prompt
assert "i_am_idle()" in prompt
assert "Product Owner" in prompt and "Head of Marketing" in prompt
assert "do NOT" in prompt.lower() or "do not" in prompt.lower()
+11 -44
View File
@@ -1,14 +1,12 @@
"""Gateway cooldown is consulted only when ROBOCO_GATEWAY_ENABLED=true.
"""Gateway spawn-cooldown gate.
`gateway_pre_spawn_check` short-circuits to ``("spawn", ...)`` when
``settings.gateway_enabled`` is False so the legacy spawn path stays
unchanged. When the flag is True it must reach
``roboco.services.gateway.trigger_filter.decide_spawn`` whose 4-rule
cooldown machinery is the real spawn gate.
`gateway_pre_spawn_check` always reaches
``roboco.services.gateway.trigger_filter.decide_spawn`` (except the no-task
carve-out), whose 4-rule cooldown machinery is the real spawn gate.
Without these assertions a regression that flips the flag back to False
(or drops the call site entirely) would leave the orchestrator with no
server-side spawn cooldown beyond ``_pm_respawn_should_gate``.
Without these assertions a regression that drops the call site would leave
the orchestrator with no server-side spawn cooldown beyond
``_pm_respawn_should_gate``.
"""
from __future__ import annotations
@@ -17,39 +15,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime import orchestrator as orchestrator_module
from roboco.runtime.orchestrator import gateway_pre_spawn_check
from roboco.services.gateway.trigger_filter import Decision, SpawnDecision
@pytest.mark.asyncio
async def test_gateway_disabled_short_circuits_without_calling_decide_spawn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Flag off -> short-circuit. ``decide_spawn`` must not be invoked."""
monkeypatch.setattr(orchestrator_module.settings, "gateway_enabled", False)
with patch(
"roboco.services.gateway.trigger_filter.decide_spawn"
) as mock_decide_spawn:
outcome, reason = await gateway_pre_spawn_check(
task_id=str(uuid4()),
trigger_kind="scan",
target_role="developer",
)
assert outcome == "spawn"
assert "gateway disabled" in reason
mock_decide_spawn.assert_not_called()
@pytest.mark.asyncio
async def test_gateway_enabled_consults_decide_spawn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Flag on -> ``decide_spawn`` is invoked and its decision propagates."""
monkeypatch.setattr(orchestrator_module.settings, "gateway_enabled", True)
async def test_gateway_enabled_consults_decide_spawn() -> None:
"""``decide_spawn`` is invoked and its decision propagates."""
task_id = str(uuid4())
# Stub task row that decide_spawn will receive.
@@ -104,16 +76,11 @@ async def test_gateway_enabled_consults_decide_spawn(
@pytest.mark.asyncio
async def test_gateway_enabled_skips_decide_spawn_when_no_task_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def test_gateway_enabled_skips_decide_spawn_when_no_task_id() -> None:
"""No task_id -> early return; ``decide_spawn`` not called.
Documents the no-task-spawn carve-out: idle PM ticks pass through
even with the gateway enabled.
Documents the no-task-spawn carve-out: idle PM ticks pass through.
"""
monkeypatch.setattr(orchestrator_module.settings, "gateway_enabled", True)
with patch(
"roboco.services.gateway.trigger_filter.decide_spawn"
) as mock_decide_spawn:
@@ -0,0 +1,216 @@
"""Coordination/board tasks (product_id set, project_id NULL) are not git-gated.
Regression: after tasks.project_id became nullable, a board/fan-out task that
carries a product but no repo of its own was refused at spawn
("task has no project") and auto-blocked as stuck ("Task missing branch_name"),
because the orchestrator's readiness and stuck checks assumed every task does
git work. `_is_coordination_task` marks these tasks so the project/branch/
git-token gates are skipped for them while still gating genuinely unroutable
tasks (neither project nor product) and ordinary code tasks.
"""
from __future__ import annotations
from typing import Any
from roboco.runtime.orchestrator import (
AgentOrchestrator,
_branch_is_expected,
_is_coordination_task,
)
def _bare_orchestrator() -> AgentOrchestrator:
"""An instance without the heavy __init__ — these methods need no deps."""
return object.__new__(AgentOrchestrator)
# ---------------------------------------------------------------------------
# _is_coordination_task
# ---------------------------------------------------------------------------
def test_coordination_task_when_product_without_project() -> None:
assert _is_coordination_task({"project_id": None, "product_id": "p1"}) is True
def test_not_coordination_when_project_set() -> None:
# A cell subtask carries both a resolved project and the product lineage.
assert _is_coordination_task({"project_id": "r1", "product_id": "p1"}) is False
def test_not_coordination_when_only_project() -> None:
assert _is_coordination_task({"project_id": "r1", "product_id": None}) is False
def test_not_coordination_when_neither() -> None:
# Genuinely unroutable — stays gated so it is auto-blocked, not spawned.
assert _is_coordination_task({"project_id": None, "product_id": None}) is False
# ---------------------------------------------------------------------------
# _readiness_check_task
# ---------------------------------------------------------------------------
def _task(**over: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"acceptance_criteria": ["does the thing"],
"status": "pending",
"project_id": None,
"product_id": None,
"project_slug": None,
"branch_name": None,
}
base.update(over)
return base
def test_readiness_allows_coordination_task_without_project() -> None:
orch = _bare_orchestrator()
reason = orch._readiness_check_task(
"product-owner", _task(product_id="p1", status="pending")
)
assert reason is None
def test_readiness_blocks_task_with_neither_project_nor_product() -> None:
orch = _bare_orchestrator()
reason = orch._readiness_check_task("product-owner", _task())
assert reason == "task has no project"
def test_readiness_skips_branch_gate_for_coordination_task() -> None:
# in_progress + no branch would trip the branch gate for a code task, but a
# coordination task does no git so it must not be branch-gated.
orch = _bare_orchestrator()
reason = orch._readiness_check_task(
"main-pm", _task(product_id="p1", status="in_progress", branch_name=None)
)
assert reason is None
def test_readiness_still_gates_code_task_without_project() -> None:
orch = _bare_orchestrator()
reason = orch._readiness_check_task("be-dev-1", _task(status="pending"))
assert reason == "task has no project"
def test_readiness_still_branch_gates_code_task_in_progress() -> None:
orch = _bare_orchestrator()
reason = orch._readiness_check_task(
"be-dev-1",
_task(
project_id="r1",
project_slug="roboco",
status="in_progress",
branch_name=None,
),
)
assert reason is not None
assert "branch_name" in reason
# ---------------------------------------------------------------------------
# _check_stuck_conditions
# ---------------------------------------------------------------------------
_GOOD_DESC = "A real coordination task description"
def test_stuck_check_ignores_missing_branch_for_coordination_task() -> None:
orch = _bare_orchestrator()
issues = orch._check_stuck_conditions(
{
"project_id": None,
"product_id": "p1",
"branch_name": None,
"description": _GOOD_DESC,
}
)
assert "Task missing branch_name" not in issues
assert issues == []
def test_stuck_check_flags_missing_branch_for_claimed_code_task() -> None:
# A claimed code task SHOULD already own a branch (auto-created on claim).
orch = _bare_orchestrator()
issues = orch._check_stuck_conditions(
{
"project_id": "r1",
"product_id": None,
"branch_name": None,
"status": "claimed",
"description": _GOOD_DESC,
}
)
assert "Task missing branch_name" in issues
def test_stuck_check_ignores_missing_branch_for_pending_code_task() -> None:
# #18: a pending, never-claimed code task legitimately has no branch (the
# branch is created at claim). It must NOT be flagged/auto-blocked here —
# the proven-live bug was a pending task auto-blocked every 30s.
orch = _bare_orchestrator()
issues = orch._check_stuck_conditions(
{
"project_id": "r1",
"product_id": None,
"branch_name": None,
"status": "pending",
"description": _GOOD_DESC,
}
)
assert "Task missing branch_name" not in issues
assert issues == []
# ---------------------------------------------------------------------------
# _branch_is_expected (#18 gate)
# ---------------------------------------------------------------------------
def test_branch_not_expected_for_pending_code_task() -> None:
assert (
_branch_is_expected(
{"project_id": "r1", "product_id": None, "status": "pending"}
)
is False
)
def test_branch_not_expected_for_backlog_code_task() -> None:
assert (
_branch_is_expected(
{"project_id": "r1", "product_id": None, "status": "backlog"}
)
is False
)
def test_branch_expected_for_claimed_code_task() -> None:
assert (
_branch_is_expected(
{"project_id": "r1", "product_id": None, "status": "claimed"}
)
is True
)
def test_branch_expected_for_in_progress_code_task() -> None:
assert (
_branch_is_expected(
{"project_id": "r1", "product_id": None, "status": "in_progress"}
)
is True
)
def test_branch_never_expected_for_coordination_task() -> None:
# Even at in_progress, a coordination task does no git → never branch-gated.
assert (
_branch_is_expected(
{"project_id": None, "product_id": "p1", "status": "in_progress"}
)
is False
)
+7 -4
View File
@@ -18,11 +18,14 @@ class TestBuildForRole:
role="developer",
team="backend",
workspace_path=Path("/data/workspaces/roboco/backend/be-dev-1"),
agent_model="minimax-m2.7:cloud",
agent_model="minimax-m3:cloud",
)
)
assert "i_am_done" in m.flow_tools
assert "commit" in m.do_tools
# Issue #8: the developer manifest must carry `evidence` so the
# do-server registers mcp__roboco-do__evidence inside the container.
assert "evidence" in m.do_tools
assert "Edit" in m.write_tools
assert m.subagent_allowed is False
assert m.subagent_model is None # devs don't dispatch
@@ -36,11 +39,11 @@ class TestBuildForRole:
role="main_pm",
team="board",
workspace_path=Path("/data/workspaces/roboco/board/main-pm"),
agent_model="minimax-m2.7:cloud",
agent_model="minimax-m3:cloud",
)
)
assert m.subagent_allowed is True
assert m.subagent_model == "minimax-m2.7:cloud"
assert m.subagent_model == "minimax-m3:cloud"
def test_qa_manifest_no_write(self) -> None:
m = build_for_role(
@@ -49,7 +52,7 @@ class TestBuildForRole:
role="qa",
team="backend",
workspace_path=Path("/data/workspaces/roboco/backend/be-qa"),
agent_model="minimax-m2.7:cloud",
agent_model="minimax-m3:cloud",
)
)
assert m.write_tools == []
+5 -5
View File
@@ -36,13 +36,13 @@ def _run(cmd: str) -> int:
def test_blocks_internal_curl_to_orchestrator() -> None:
assert (
_run("curl http://roboco-orchestrator:8000/api/v2/flow/main_pm/delegate")
_run("curl http://roboco-orchestrator:8000/api/v1/flow/main_pm/delegate")
== _DENIED
)
def test_blocks_internal_curl_to_localhost() -> None:
assert _run("curl http://localhost:8000/api/v2/flow/developer/i_am_done") == _DENIED
assert _run("curl http://localhost:8000/api/v1/flow/developer/i_am_done") == _DENIED
def test_blocks_internal_curl_to_127() -> None:
@@ -266,7 +266,7 @@ def test_blocks_smoke17_python_httpx_heredoc_to_orchestrator() -> None:
cmd = (
"python3 << 'PYEOF'\n"
"import httpx\n"
'httpx.post("http://roboco-orchestrator:8000/api/v2/flow/'
'httpx.post("http://roboco-orchestrator:8000/api/v1/flow/'
'developer/i_will_work_on",\n'
' headers={"X-Agent-ID": "00000000-0000-0000-0001-'
'000000000001", "X-Agent-Role": "developer"})\n'
@@ -294,7 +294,7 @@ def test_blocks_python_urllib_to_orchestrator() -> None:
def test_blocks_node_fetch_to_internal_host() -> None:
assert (
_run("node -e \"fetch('http://roboco-orchestrator:8000/api/v2/do/note')\"")
_run("node -e \"fetch('http://roboco-orchestrator:8000/api/v1/do/note')\"")
== _DENIED
)
@@ -314,7 +314,7 @@ def test_blocks_aiohttp_to_orchestrator() -> None:
"import aiohttp, asyncio\n"
"async def m():\n"
" async with aiohttp.ClientSession() as s:\n"
' await s.post("http://roboco-orchestrator:8000/api/v2/flow/'
' await s.post("http://roboco-orchestrator:8000/api/v1/flow/'
'developer/i_am_done")\n'
"asyncio.run(m())\n"
"EOF"
-82
View File
@@ -5,11 +5,6 @@ from __future__ import annotations
from uuid import uuid4
import pytest
from roboco.models.audit import (
AuditEventType,
PermissionDenialContext,
StateTransitionDenialContext,
)
from roboco.services.audit import (
AuditService,
_AuditEvent,
@@ -55,28 +50,6 @@ def test_coerce_uuid_returns_none_for_invalid() -> None:
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_log_permission_denial_does_not_raise(svc: AuditService) -> None:
await svc.log_permission_denial(
PermissionDenialContext(
agent_id=uuid4(),
action="create_task",
resource="task",
reason="not allowed",
)
)
@pytest.mark.asyncio
async def test_log_channel_access_denial(svc: AuditService) -> None:
await svc.log_channel_access_denial(
agent_id=str(uuid4()),
channel_slug="backend-cell",
access_type="write",
reason="not member",
)
@pytest.mark.asyncio
async def test_log_task_action_denial(svc: AuditService) -> None:
await svc.log_task_action_denial(
@@ -88,40 +61,6 @@ async def test_log_task_action_denial(svc: AuditService) -> None:
)
@pytest.mark.asyncio
async def test_log_state_transition_denial(svc: AuditService) -> None:
await svc.log_state_transition_denial(
StateTransitionDenialContext(
agent_id=uuid4(),
agent_role="qa",
task_id=uuid4(),
current_status="pending",
target_status="completed",
reason="invalid transition",
)
)
@pytest.mark.asyncio
async def test_log_notification_denial(svc: AuditService) -> None:
await svc.log_notification_denial(
agent_id=str(uuid4()),
agent_role="developer",
notification_type="blocker",
reason="dev cannot notify qa directly",
)
@pytest.mark.asyncio
async def test_log_security_event(svc: AuditService) -> None:
await svc.log_security_event(
event_type=AuditEventType.PERMISSION_DENIED,
agent_id=str(uuid4()),
description="bad token",
details={"reason": "bad token"},
)
@pytest.mark.asyncio
async def test_log_event_generic(svc: AuditService) -> None:
await svc.log_event(
@@ -142,27 +81,6 @@ async def test_log_agent_event(svc: AuditService) -> None:
)
@pytest.mark.asyncio
async def test_log_pm_override(svc: AuditService) -> None:
await svc.log_pm_override(
agent_id=uuid4(),
task_id=uuid4(),
action="complete_with_cancelled_subtasks",
justification="subtasks were superseded",
cancelled_subtask_ids=[str(uuid4())],
)
@pytest.mark.asyncio
async def test_log_pm_override_no_subtasks(svc: AuditService) -> None:
await svc.log_pm_override(
agent_id=uuid4(),
task_id=uuid4(),
action="force_complete",
justification="all done",
)
@pytest.mark.asyncio
async def test_log_task_event_basic(svc: AuditService) -> None:
await svc.log_task_event(
@@ -268,12 +268,10 @@ async def test_submit_for_qa_writes_audit_with_dev_agent_id(
sequence=0,
plan={"steps": ["impl"]},
estimated_complexity=Complexity.MEDIUM,
execution_log={},
checkpoints=[],
progress_updates=[{"at": "t0", "note": "started"}],
commits=[{"sha": "abc123", "message": "[AUDIT001] init"}],
documents=[],
outputs=[],
dev_notes="impl complete",
self_verified=True,
)
@@ -0,0 +1,329 @@
"""#14: a descendant executable task is never assigned to a board/advisory role.
The main_pm -> product_owner escalation rung used to hand an in_progress child
code task to the Product Owner and mark it BLOCKED. The board has no verb to
claim/build/complete cell-executed work, so the dev's finished work deadlocked.
The guard covers every CELL-executed task type code, documentation, AND
design because a board role has no verb to own any of them. The shared write
primitive ``TaskService.apply_escalation`` now diverts such an escalation: the
task is released to PENDING for a role-matched cell claim instead of being
stranded on a board role.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.models.base import AgentRole, TaskStatus, TaskType
from roboco.services.task import TaskService, _is_descendant_executable_task
def _bind(svc: TaskService, name: str, value: object) -> None:
object.__setattr__(svc, name, value)
def _service() -> TaskService:
session = MagicMock()
session.flush = AsyncMock()
return TaskService(session)
# ---------------------------------------------------------------------------
# _is_descendant_executable_task (pure)
# ---------------------------------------------------------------------------
def test_descendant_code_task_is_flagged() -> None:
task = MagicMock(parent_task_id=uuid4(), task_type=TaskType.CODE)
assert _is_descendant_executable_task(task) is True
def test_descendant_documentation_task_is_flagged() -> None:
# #14 broaden: documentation is cell-executed (documenter), not board work.
task = MagicMock(parent_task_id=uuid4(), task_type=TaskType.DOCUMENTATION)
assert _is_descendant_executable_task(task) is True
def test_descendant_design_task_is_flagged() -> None:
# #14 broaden: design is cell-executed (UX/design cell), not board work.
task = MagicMock(parent_task_id=uuid4(), task_type=TaskType.DESIGN)
assert _is_descendant_executable_task(task) is True
def test_root_code_task_is_not_descendant() -> None:
# A root task can legitimately escalate up the chain (the CEO reviews it).
task = MagicMock(parent_task_id=None, task_type=TaskType.CODE)
assert _is_descendant_executable_task(task) is False
def test_root_documentation_task_is_not_descendant() -> None:
# Roots are reviewed up the chain regardless of (executable) type.
task = MagicMock(parent_task_id=None, task_type=TaskType.DOCUMENTATION)
assert _is_descendant_executable_task(task) is False
def test_descendant_planning_task_is_not_executable() -> None:
# PLANNING routes to a PM, not a cell agent — not diverted by the guard.
task = MagicMock(parent_task_id=uuid4(), task_type=TaskType.PLANNING)
assert _is_descendant_executable_task(task) is False
def test_descendant_research_task_is_not_executable() -> None:
task = MagicMock(parent_task_id=uuid4(), task_type=TaskType.RESEARCH)
assert _is_descendant_executable_task(task) is False
def test_descendant_administrative_task_is_not_executable() -> None:
task = MagicMock(parent_task_id=uuid4(), task_type=TaskType.ADMINISTRATIVE)
assert _is_descendant_executable_task(task) is False
def test_code_task_type_as_raw_string_is_flagged() -> None:
# Detached/partially-hydrated rows may surface task_type as a raw string.
task = MagicMock(parent_task_id=uuid4(), task_type="code")
assert _is_descendant_executable_task(task) is True
def test_documentation_task_type_as_raw_string_is_flagged() -> None:
task = MagicMock(parent_task_id=uuid4(), task_type="documentation")
assert _is_descendant_executable_task(task) is True
# ---------------------------------------------------------------------------
# apply_escalation board-role divert
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_apply_escalation_diverts_descendant_code_to_board() -> None:
svc = _service()
target_id = uuid4()
task = MagicMock(
id=uuid4(),
parent_task_id=uuid4(),
task_type=TaskType.CODE,
assigned_to=uuid4(),
blocker_raised_by=None,
status=TaskStatus.IN_PROGRESS,
)
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=True))
release_mock = AsyncMock()
_bind(svc, "_release_code_task_to_pool", release_mock)
await svc.apply_escalation(
task=task,
target_agent_id=target_id,
escalator_slug="main-pm",
target_slug="product-owner",
reason="please review",
)
# Diverted to the pool release — NOT blocked, NOT reassigned to the board.
release_mock.assert_awaited_once()
assert task.status == TaskStatus.IN_PROGRESS # untouched by the guard branch
assert task.assigned_to != target_id
@pytest.mark.asyncio
async def test_apply_escalation_diverts_descendant_documentation_to_board() -> None:
# #14 broaden: a descendant DOCUMENTATION task escalated to a board role is
# diverted too — the board has no verb to write/complete docs either.
svc = _service()
target_id = uuid4()
task = MagicMock(
id=uuid4(),
parent_task_id=uuid4(),
task_type=TaskType.DOCUMENTATION,
assigned_to=uuid4(),
blocker_raised_by=None,
status=TaskStatus.IN_PROGRESS,
)
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=True))
release_mock = AsyncMock()
_bind(svc, "_release_code_task_to_pool", release_mock)
await svc.apply_escalation(
task=task,
target_agent_id=target_id,
escalator_slug="main-pm",
target_slug="head-marketing",
reason="please review docs",
)
release_mock.assert_awaited_once()
assert task.status == TaskStatus.IN_PROGRESS # untouched by the guard branch
assert task.assigned_to != target_id
@pytest.mark.asyncio
async def test_apply_escalation_diverts_descendant_design_to_board() -> None:
# #14 broaden: a descendant DESIGN task escalated to a board role is diverted.
svc = _service()
target_id = uuid4()
task = MagicMock(
id=uuid4(),
parent_task_id=uuid4(),
task_type=TaskType.DESIGN,
assigned_to=uuid4(),
blocker_raised_by=None,
status=TaskStatus.IN_PROGRESS,
)
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=True))
release_mock = AsyncMock()
_bind(svc, "_release_code_task_to_pool", release_mock)
await svc.apply_escalation(
task=task,
target_agent_id=target_id,
escalator_slug="ux-pm",
target_slug="product-owner",
reason="please review design",
)
release_mock.assert_awaited_once()
assert task.status == TaskStatus.IN_PROGRESS
assert task.assigned_to != target_id
@pytest.mark.asyncio
async def test_apply_escalation_blocks_descendant_planning_to_board() -> None:
# PLANNING is NOT a cell-executed type — the guard does not divert it even to
# a board target, so it follows the normal block+reassign path.
svc = _service()
target_id = uuid4()
task = MagicMock(
id=uuid4(),
parent_task_id=uuid4(),
task_type=TaskType.PLANNING,
assigned_to=uuid4(),
blocker_raised_by=None,
dev_notes=None,
status=TaskStatus.IN_PROGRESS,
)
board_check = AsyncMock(return_value=True)
_bind(svc, "_is_board_advisory_agent", board_check)
release_mock = AsyncMock()
_bind(svc, "_release_code_task_to_pool", release_mock)
await svc.apply_escalation(
task=task,
target_agent_id=target_id,
escalator_slug="main-pm",
target_slug="product-owner",
reason="planning review",
)
release_mock.assert_not_called()
assert task.status == TaskStatus.BLOCKED
assert task.assigned_to == target_id
@pytest.mark.asyncio
async def test_apply_escalation_proceeds_for_non_board_target() -> None:
svc = _service()
target_id = uuid4()
task = MagicMock(
id=uuid4(),
parent_task_id=uuid4(),
task_type=TaskType.CODE,
assigned_to=uuid4(),
blocker_raised_by=None,
dev_notes=None,
status=TaskStatus.IN_PROGRESS,
)
_bind(svc, "_is_board_advisory_agent", AsyncMock(return_value=False))
release_mock = AsyncMock()
_bind(svc, "_release_code_task_to_pool", release_mock)
await svc.apply_escalation(
task=task,
target_agent_id=target_id,
escalator_slug="be-pm",
target_slug="main-pm",
reason="cell blocked",
)
# Normal escalation: blocked + reassigned to the (non-board) target.
release_mock.assert_not_called()
assert task.status == TaskStatus.BLOCKED
assert task.assigned_to == target_id
@pytest.mark.asyncio
async def test_apply_escalation_blocks_root_code_task_to_board_target() -> None:
# A ROOT code task is not a descendant — the guard does not fire even when
# the target is a board role (the CEO/board reviews roots legitimately).
svc = _service()
target_id = uuid4()
task = MagicMock(
id=uuid4(),
parent_task_id=None,
task_type=TaskType.CODE,
assigned_to=uuid4(),
blocker_raised_by=None,
dev_notes=None,
status=TaskStatus.IN_PROGRESS,
)
board_check = AsyncMock(return_value=True)
_bind(svc, "_is_board_advisory_agent", board_check)
release_mock = AsyncMock()
_bind(svc, "_release_code_task_to_pool", release_mock)
await svc.apply_escalation(
task=task,
target_agent_id=target_id,
escalator_slug="main-pm",
target_slug="product-owner",
reason="root review",
)
# Guard short-circuits on _is_descendant_executable_task BEFORE the board
# check, so a root task escalates normally.
board_check.assert_not_called()
release_mock.assert_not_called()
assert task.status == TaskStatus.BLOCKED
assert task.assigned_to == target_id
@pytest.mark.asyncio
async def test_release_code_task_to_pool_sets_pending_and_clears_assignee() -> None:
svc = _service()
task = MagicMock(
id=uuid4(),
assigned_to=uuid4(),
claimed_by=uuid4(),
active_claimant_id=uuid4(),
dev_notes="prior",
status=TaskStatus.IN_PROGRESS,
)
await svc._release_code_task_to_pool(
task=task,
escalator_slug="main-pm",
blocked_target_slug="product-owner",
reason="cannot own code",
)
assert task.status == TaskStatus.PENDING
assert task.assigned_to is None
assert task.claimed_by is None
assert task.active_claimant_id is None
assert "ESCALATION REDIRECTED" in task.dev_notes
@pytest.mark.asyncio
async def test_is_board_advisory_agent_classifies_roles() -> None:
for role, expected in [
(AgentRole.PRODUCT_OWNER, True),
(AgentRole.HEAD_MARKETING, True),
(AgentRole.AUDITOR, True),
(AgentRole.MAIN_PM, False),
(AgentRole.CELL_PM, False),
(AgentRole.DEVELOPER, False),
]:
session = MagicMock()
result = MagicMock()
result.scalar_one_or_none = MagicMock(return_value=role)
session.execute = AsyncMock(return_value=result)
svc = TaskService(session)
assert await svc._is_board_advisory_agent(uuid4()) is expected
+52
View File
@@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.base import NotFoundError
from roboco.services.git import GitService
@@ -247,3 +248,54 @@ async def test_pr_merge_returns_merge_commit_dict() -> None:
with _patch_project_service(fake_project):
out = await svc.pr_merge(11, target="master")
assert out == {"merge_commit_sha": "abc123sha"}
# ---------------------------------------------------------------------------
# commit: stages + commits a large changeset with the longer git timeout
# (issue #13 — the panel commit verb timed out on the 30s default budget).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_commit_uses_longer_timeout_for_staging_and_commit() -> None:
"""`add`/`commit` must run with the commit-timeout, not the default.
Large multi-file changesets exceeded the 30s default git timeout. The
staging (`git add`) and `git commit` ops now pass
`settings.git_commit_timeout_seconds` so big changesets don't time out.
"""
svc = _service()
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_assert_on_task_branch", AsyncMock())
_bind(svc, "_task_for_branch", AsyncMock(return_value=None))
_bind(svc, "_parse_commit_stats", MagicMock(return_value=(1, 0, 1)))
timeouts_by_subcmd: dict[str, int | None] = {}
async def _run_git(
_workspace: Path,
args: list[str],
check: bool = True,
token: str | None = None,
timeout: int | None = None,
) -> MagicMock:
del check, token
timeouts_by_subcmd[args[0]] = timeout
if args[:2] == ["log", "-1"]:
return MagicMock(stdout="deadbeef|feat: big change\n", returncode=0)
return MagicMock(stdout="", returncode=0)
_bind(svc, "_run_git", AsyncMock(side_effect=_run_git))
out = await svc.commit(
branch_name="feature/frontend/abc12345",
message="implement the panel dashboard layout and routing",
task_id=uuid4(),
)
assert out["sha"] == "deadbeef"
# Staging + commit ran with the longer commit budget...
assert timeouts_by_subcmd["add"] == settings.git_commit_timeout_seconds
assert timeouts_by_subcmd["commit"] == settings.git_commit_timeout_seconds
# ...while the cheap read-only ops kept the default (None → default budget).
assert timeouts_by_subcmd["log"] is None
+16
View File
@@ -221,6 +221,22 @@ async def test_send_a2a_notification(svc: NotificationService) -> None:
assert any(row.priority == NotificationPriority.URGENT for row in db.added)
@pytest.mark.asyncio
async def test_send_board_review_complete_notification(
svc: NotificationService,
) -> None:
"""Board-review-complete handoff is an APPROVAL notification to the CEO
carrying the task_id (cluster C5 / finding #2)."""
aid = uuid4()
db = _FakeDb(agent_uuid=aid)
with _patch_db_context(db):
await svc.send_board_review_complete_notification(task_id="t1")
assert any("Board review complete" in row.subject for row in db.added)
assert any(row.type == NotificationType.APPROVAL for row in db.added)
assert any(row.priority == NotificationPriority.HIGH for row in db.added)
assert any(row.related_task_id == "t1" for row in db.added)
@pytest.mark.asyncio
async def test_send_ack_notification(svc: NotificationService) -> None:
aid = uuid4()
+1 -92
View File
@@ -121,24 +121,6 @@ def test_can_notify_pm_to_dev(svc: PermissionService) -> None:
assert svc.can_notify(sender, recipient) is True
# ---------------------------------------------------------------------------
# Communication matrix
# ---------------------------------------------------------------------------
def test_can_communicate_within_cell(svc: PermissionService) -> None:
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
qa = _ctx(AgentRole.QA, team=Team.BACKEND)
assert svc.can_communicate(dev, qa) is True
def test_can_communicate_across_cells_via_pm(svc: PermissionService) -> None:
"""Communication matrix returns a bool — exact result depends on the matrix."""
main_pm = _ctx(AgentRole.MAIN_PM)
dev = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert isinstance(svc.can_communicate(main_pm, dev), bool)
# ---------------------------------------------------------------------------
# Task action permissions
# ---------------------------------------------------------------------------
@@ -208,33 +190,6 @@ def test_check_all_returns_dict(svc: PermissionService) -> None:
assert "level" in result
# ---------------------------------------------------------------------------
# Slug-based shortcuts
# ---------------------------------------------------------------------------
def test_can_agent_read_channel_known_slug(svc: PermissionService) -> None:
"""Pass a known agent slug; service should resolve role+team and decide."""
# be-dev-1 is in AGENT_ROLE_MAP as a developer in backend.
result = svc.can_agent_read_channel("be-dev-1", "backend-cell")
assert isinstance(result, bool)
def test_can_agent_read_channel_unknown_slug(svc: PermissionService) -> None:
"""Unknown slug → False (deny by default)."""
assert svc.can_agent_read_channel("ghost-agent", "backend-cell") is False
def test_can_agent_send_notifications_known_slug(svc: PermissionService) -> None:
"""main-pm slug should be able to send."""
assert svc.can_agent_send_notifications("main-pm") is True
def test_can_agent_send_notifications_unknown_slug(svc: PermissionService) -> None:
"""Unknown slug → False."""
assert svc.can_agent_send_notifications("ghost-agent") is False
# ---------------------------------------------------------------------------
# KB permissions
# ---------------------------------------------------------------------------
@@ -285,39 +240,10 @@ def test_can_notify_cell_pm_to_dev_in_same_team(svc: PermissionService) -> None:
# ---------------------------------------------------------------------------
# Communication matrix edge cases
# Channel bypass edge cases
# ---------------------------------------------------------------------------
def test_can_communicate_same_role_same_team(svc: PermissionService) -> None:
a = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
b = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
assert svc.can_communicate(a, b) is True
def test_can_communicate_dev_to_qa_different_cells(
svc: PermissionService,
) -> None:
"""Cell members can't directly communicate cross-cell."""
a = _ctx(AgentRole.DEVELOPER, team=Team.BACKEND)
b = _ctx(AgentRole.QA, team=Team.FRONTEND)
assert svc.can_communicate(a, b) is False
# ---------------------------------------------------------------------------
# Slug-based shortcuts (more cases)
# ---------------------------------------------------------------------------
def test_can_agent_write_channel_known(svc: PermissionService) -> None:
"""be-pm should be able to write to backend-cell."""
assert isinstance(svc.can_agent_write_channel("be-pm", "backend-cell"), bool)
def test_can_agent_write_channel_unknown_slug(svc: PermissionService) -> None:
assert svc.can_agent_write_channel("ghost-agent", "any") is False
def test_can_read_channel_for_main_pm_unknown_bypasses(
svc: PermissionService,
) -> None:
@@ -334,13 +260,6 @@ def test_can_write_channel_for_ceo_unknown_bypasses(
assert svc.can_write_channel(ceo, "ghost-channel") is True
def test_can_agent_write_channel_unknown_channel(
svc: PermissionService,
) -> None:
"""Unknown channel slug → False (no panic)."""
assert svc.can_agent_write_channel("be-dev-1", "ghost-channel") is False
# ---------------------------------------------------------------------------
# Channel read for non-bypass roles (covers _check_channel_access_for_agent)
# ---------------------------------------------------------------------------
@@ -376,16 +295,6 @@ def test_can_notify_developer_returns_false(svc: PermissionService) -> None:
assert svc.can_notify(dev, other) is False
# ---------------------------------------------------------------------------
# can_agent_read_channel for unknown channel (line 377)
# ---------------------------------------------------------------------------
def test_can_agent_read_channel_unknown_channel(svc: PermissionService) -> None:
"""Channel not in CHANNEL_ACCESS → False (line 377)."""
assert svc.can_agent_read_channel("be-dev-1", "ghost-channel-z") is False
# ---------------------------------------------------------------------------
# can_notify list scope (lines 253-258)
# ---------------------------------------------------------------------------
+32
View File
@@ -592,3 +592,35 @@ async def test_escalate_up_to_role_returns_none_for_unknown_role() -> None:
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.escalate_up_to_role(uuid4(), task.id, "bogus_role", "reason")
assert out is None
# ---------------------------------------------------------------------------
# _ensure_branch_for_task — coordination/fan-out tasks do no git
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ensure_branch_returns_existing_branch() -> None:
"""An already-branched task short-circuits before any project check."""
svc = TaskService(MagicMock())
task = MagicMock(branch_name="feature/backend/abc12345", project_id=None)
assert (
await svc._ensure_branch_for_task(task, uuid4()) == "feature/backend/abc12345"
)
@pytest.mark.asyncio
async def test_ensure_branch_skips_coordination_task() -> None:
"""A product-backed task with no repo of its own gets no branch (not raised)."""
svc = TaskService(MagicMock())
task = MagicMock(branch_name=None, project_id=None, product_id=uuid4())
assert await svc._ensure_branch_for_task(task, uuid4()) == ""
@pytest.mark.asyncio
async def test_ensure_branch_raises_when_neither_project_nor_product() -> None:
"""A task with neither a project nor a product is genuinely misconfigured."""
svc = TaskService(MagicMock())
task = MagicMock(branch_name=None, project_id=None, product_id=None)
with pytest.raises(ValueError, match="project_id"):
await svc._ensure_branch_for_task(task, uuid4())
@@ -102,12 +102,10 @@ async def _seed_claimed_task(session: AsyncSession) -> UUID:
sequence=0,
plan={"steps": ["heartbeat"]},
estimated_complexity=Complexity.LOW,
execution_log={},
checkpoints=[],
progress_updates=[],
commits=[],
documents=[],
outputs=[],
last_heartbeat_at=None,
)
session.add(task)
@@ -220,12 +218,10 @@ async def _seed_pending_task_for_claim(
sequence=0,
plan=None,
estimated_complexity=Complexity.LOW,
execution_log={},
checkpoints=[],
progress_updates=[],
commits=[],
documents=[],
outputs=[],
last_heartbeat_at=None,
)
session.add(task)
@@ -0,0 +1,308 @@
"""Unit tests for the post-clone dev-dependency install (issue #10).
Per-agent workspace clones never had the project's dev dependencies
installed, so the `make quality` gate (ruff/mypy/pytest for Python, the TS
toolchain for the panel) was missing and devs re-downloaded tooling per
task. `WorkspaceService.install_dev_deps` now runs `uv sync` / `pnpm install`
after cloning, idempotently (skipped when lockfiles are unchanged).
These tests cover the pure detection/digest helpers and the install method's
ecosystem detection, idempotency, and best-effort failure handling. They run
without a DB or a real git remote.
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.workspace import (
_DEP_INSTALL_MARKER,
WorkspaceService,
_detect_dep_commands,
_lockfile_digest,
)
if TYPE_CHECKING:
from pathlib import Path
# Two installs expected when the lockfile changes between calls (named to
# satisfy ruff PLR2004 — magic-value comparison).
_EXPECTED_RERUN_INSTALLS = 2
def _service() -> WorkspaceService:
"""Build a WorkspaceService over a MagicMock session."""
session = MagicMock()
session.execute = AsyncMock()
return WorkspaceService(session)
def _make_workspace(tmp_path: Path) -> Path:
"""A workspace dir with a `.git/` so the marker has somewhere to live."""
workspace = tmp_path / "roboco" / "backend" / "be-dev-1"
(workspace / ".git").mkdir(parents=True)
return workspace
# ---------------------------------------------------------------------------
# _detect_dep_commands
# ---------------------------------------------------------------------------
def test_detect_python_project(tmp_path: Path) -> None:
"""A `pyproject.toml` yields a `uv sync` command."""
ws = _make_workspace(tmp_path)
(ws / "pyproject.toml").write_text("[project]\nname = 'x'\n")
commands = _detect_dep_commands(ws)
assert commands == [("uv sync", ["uv", "sync"])]
def test_detect_pnpm_project(tmp_path: Path) -> None:
"""A `pnpm-lock.yaml` yields a frozen-lockfile pnpm install."""
ws = _make_workspace(tmp_path)
(ws / "package.json").write_text("{}")
(ws / "pnpm-lock.yaml").write_text("lockfileVersion: 9\n")
commands = _detect_dep_commands(ws)
assert commands == [("pnpm install", ["pnpm", "install", "--frozen-lockfile"])]
def test_detect_npm_ci_when_package_lock(tmp_path: Path) -> None:
"""`package-lock.json` (no pnpm lock) yields `npm ci`."""
ws = _make_workspace(tmp_path)
(ws / "package.json").write_text("{}")
(ws / "package-lock.json").write_text("{}")
commands = _detect_dep_commands(ws)
assert commands == [("npm ci", ["npm", "ci"])]
def test_detect_npm_install_bare_package_json(tmp_path: Path) -> None:
"""A bare `package.json` (no lockfile) falls back to `npm install`."""
ws = _make_workspace(tmp_path)
(ws / "package.json").write_text("{}")
commands = _detect_dep_commands(ws)
assert commands == [("npm install", ["npm", "install"])]
def test_detect_monorepo_both_ecosystems(tmp_path: Path) -> None:
"""A Python + pnpm monorepo yields both install commands."""
ws = _make_workspace(tmp_path)
(ws / "pyproject.toml").write_text("[project]\nname = 'x'\n")
(ws / "package.json").write_text("{}")
(ws / "pnpm-lock.yaml").write_text("lockfileVersion: 9\n")
commands = _detect_dep_commands(ws)
assert ("uv sync", ["uv", "sync"]) in commands
assert ("pnpm install", ["pnpm", "install", "--frozen-lockfile"]) in commands
def test_detect_nothing_to_install(tmp_path: Path) -> None:
"""A repo with no recognized manifest yields no commands."""
ws = _make_workspace(tmp_path)
assert _detect_dep_commands(ws) == []
# ---------------------------------------------------------------------------
# _lockfile_digest
# ---------------------------------------------------------------------------
def test_lockfile_digest_none_when_no_lockfiles(tmp_path: Path) -> None:
"""No manifests → None (nothing to hash, nothing to install)."""
ws = _make_workspace(tmp_path)
assert _lockfile_digest(ws) is None
def test_lockfile_digest_changes_with_content(tmp_path: Path) -> None:
"""Editing a lockfile changes the digest (so a re-install is triggered)."""
ws = _make_workspace(tmp_path)
lock = ws / "uv.lock"
lock.write_text("a = 1\n")
digest_a = _lockfile_digest(ws)
lock.write_text("a = 2\n")
digest_b = _lockfile_digest(ws)
assert digest_a is not None
assert digest_b is not None
assert digest_a != digest_b
# ---------------------------------------------------------------------------
# install_dev_deps
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_install_runs_detected_command(tmp_path: Path) -> None:
"""A Python workspace runs `uv sync` and writes the digest marker."""
ws = _make_workspace(tmp_path)
(ws / "pyproject.toml").write_text("[project]\nname = 'x'\n")
(ws / "uv.lock").write_text("version = 1\n")
svc = _service()
captured: list[list[str]] = []
def _fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
captured.append(argv)
return subprocess.CompletedProcess(argv, returncode=0, stdout="", stderr="")
with (
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
patch("roboco.services.workspace._ensure_agent_owned"),
):
ran = await svc.install_dev_deps(ws)
assert ran is True
assert ["uv", "sync"] in captured
assert (ws / _DEP_INSTALL_MARKER).is_file()
@pytest.mark.asyncio
async def test_install_idempotent_on_unchanged_lockfiles(tmp_path: Path) -> None:
"""A second call with the same lockfiles is a no-op (cache hit)."""
ws = _make_workspace(tmp_path)
(ws / "pyproject.toml").write_text("[project]\nname = 'x'\n")
(ws / "uv.lock").write_text("version = 1\n")
svc = _service()
run_count = 0
def _fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
nonlocal run_count
run_count += 1
return subprocess.CompletedProcess(argv, returncode=0, stdout="", stderr="")
with (
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
patch("roboco.services.workspace._ensure_agent_owned"),
):
first = await svc.install_dev_deps(ws)
second = await svc.install_dev_deps(ws)
assert first is True
assert second is False
assert run_count == 1
@pytest.mark.asyncio
async def test_install_reruns_when_lockfile_changes(tmp_path: Path) -> None:
"""Changing the lockfile invalidates the marker and re-installs."""
ws = _make_workspace(tmp_path)
(ws / "pyproject.toml").write_text("[project]\nname = 'x'\n")
lock = ws / "uv.lock"
lock.write_text("version = 1\n")
svc = _service()
run_count = 0
def _fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
nonlocal run_count
run_count += 1
return subprocess.CompletedProcess(argv, returncode=0, stdout="", stderr="")
with (
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
patch("roboco.services.workspace._ensure_agent_owned"),
):
await svc.install_dev_deps(ws)
lock.write_text("version = 2\n")
await svc.install_dev_deps(ws)
assert run_count == _EXPECTED_RERUN_INSTALLS
@pytest.mark.asyncio
async def test_install_failure_is_best_effort(tmp_path: Path) -> None:
"""A non-zero install exit logs but does NOT raise, and writes no marker."""
ws = _make_workspace(tmp_path)
(ws / "pyproject.toml").write_text("[project]\nname = 'x'\n")
(ws / "uv.lock").write_text("version = 1\n")
svc = _service()
def _fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(argv, returncode=1, stdout="", stderr="boom")
with (
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
patch("roboco.services.workspace._ensure_agent_owned"),
):
ran = await svc.install_dev_deps(ws)
assert ran is False
# No marker on failure → next call retries.
assert not (ws / _DEP_INSTALL_MARKER).is_file()
@pytest.mark.asyncio
async def test_install_missing_tool_does_not_raise(tmp_path: Path) -> None:
"""A missing `uv`/`pnpm` on the host is swallowed (FileNotFoundError)."""
ws = _make_workspace(tmp_path)
(ws / "pyproject.toml").write_text("[project]\nname = 'x'\n")
(ws / "uv.lock").write_text("version = 1\n")
svc = _service()
def _fake_run(*_a: object, **_kw: object) -> subprocess.CompletedProcess[str]:
raise FileNotFoundError("uv not found")
with (
patch("roboco.services.workspace.subprocess.run", side_effect=_fake_run),
patch("roboco.services.workspace._ensure_agent_owned"),
):
ran = await svc.install_dev_deps(ws)
assert ran is False
@pytest.mark.asyncio
async def test_install_skipped_when_disabled(tmp_path: Path) -> None:
"""`workspace_install_dev_deps=False` short-circuits before any subprocess."""
ws = _make_workspace(tmp_path)
(ws / "pyproject.toml").write_text("[project]\nname = 'x'\n")
(ws / "uv.lock").write_text("version = 1\n")
svc = _service()
with (
patch(
"roboco.services.workspace.settings.workspace_install_dev_deps",
False,
),
patch("roboco.services.workspace.subprocess.run") as run_mock,
patch("roboco.services.workspace._ensure_agent_owned"),
):
ran = await svc.install_dev_deps(ws)
assert ran is False
run_mock.assert_not_called()
@pytest.mark.asyncio
async def test_install_noop_when_no_manifest(tmp_path: Path) -> None:
"""A workspace with no recognized manifest installs nothing."""
ws = _make_workspace(tmp_path)
svc = _service()
with (
patch("roboco.services.workspace.subprocess.run") as run_mock,
patch("roboco.services.workspace._ensure_agent_owned"),
):
ran = await svc.install_dev_deps(ws)
assert ran is False
run_mock.assert_not_called()
+70
View File
@@ -0,0 +1,70 @@
"""Guard: every ORM enum value must be produced by the migration chain.
StrEnum values get added to the ORM (roboco/models/base.py) freely, but a value
with no corresponding `ALTER TYPE ... ADD VALUE` migration breaks at runtime on
any DB whose enum type predates it `invalid input value for enum
notificationtype: "a2a_request"`. Alembic autogenerate does NOT detect added
enum labels, so nothing else catches this. This test renders the full chain
offline (no DB) and fails if any ORM enum value is missing from it.
"""
from __future__ import annotations
import pathlib
import re
import subprocess
import sys
from roboco.db.tables import Base # registers every ORM enum
_ROOT = pathlib.Path(__file__).resolve().parents[2]
def _orm_enum_values() -> dict[str, set[str]]:
orm: dict[str, set[str]] = {}
for table in Base.metadata.tables.values():
for col in table.columns:
name = getattr(col.type, "name", None)
labels = getattr(col.type, "enums", None)
if name and labels:
orm.setdefault(name, set()).update(labels)
return orm
def _migration_chain_enum_labels() -> dict[str, set[str]]:
rendered = subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head", "--sql"],
cwd=_ROOT,
capture_output=True,
text=True,
timeout=180,
check=False,
)
assert rendered.returncode == 0, (
"alembic offline render failed:\n" + rendered.stderr[-2000:]
)
sql = rendered.stdout
chain: dict[str, set[str]] = {}
for m in re.finditer(r"CREATE TYPE (\w+) AS ENUM \(([^)]*)\)", sql, re.S):
chain.setdefault(m.group(1), set()).update(re.findall(r"'([^']+)'", m.group(2)))
for m in re.finditer(
r"ALTER TYPE (\w+) ADD VALUE (?:IF NOT EXISTS )?'([^']+)'", sql
):
chain.setdefault(m.group(1), set()).add(m.group(2))
return chain
def test_every_orm_enum_value_is_created_by_the_migration_chain() -> None:
orm = _orm_enum_values()
chain = _migration_chain_enum_labels()
drift = {
name: sorted(orm[name] - chain.get(name, set()))
for name in orm
if orm[name] - chain.get(name, set())
}
assert not drift, (
"ORM enum values the migration chain never creates (add an "
"`ALTER TYPE <enum> ADD VALUE IF NOT EXISTS '<value>'` migration — "
"autogenerate does NOT detect added enum labels):\n"
+ "\n".join(f" {name}: {vals}" for name, vals in sorted(drift.items()))
)
-97
View File
@@ -10,10 +10,7 @@ from __future__ import annotations
from uuid import uuid4
from roboco.exceptions import (
AgentBusyError,
AgentError,
AgentNotAvailableError,
AlreadyExistsError,
AuthenticationError,
ChannelAccessDeniedError,
ChannelError,
@@ -22,17 +19,12 @@ from roboco.exceptions import (
GitError,
GitTimeoutError,
InvalidStateError,
LLMError,
NotFoundError,
NotificationError,
NotificationPermissionError,
PermissionDeniedError,
RAGError,
RobocoError,
ServiceError,
SessionClosedError,
TaskBlockedError,
TaskClaimError,
TaskError,
TaskLifecycleError,
ValidationError,
@@ -62,12 +54,6 @@ def test_not_found_error_with_details() -> None:
assert err.details["resource_id"] == str(rid)
def test_already_exists_error_with_details() -> None:
err = AlreadyExistsError("Project", "myproj", details={"existing_id": "abc"})
assert err.code == "ALREADY_EXISTS"
assert err.details["existing_id"] == "abc"
def test_validation_error_with_field_and_value() -> None:
err = ValidationError("Invalid", field="email", value="not-an-email")
assert err.code == "VALIDATION_ERROR"
@@ -193,32 +179,6 @@ def test_task_lifecycle_error_extra_kwargs() -> None:
assert err.details["extra"] == "present"
def test_task_blocked_error() -> None:
tid = uuid4()
blockers = [uuid4(), uuid4()]
err = TaskBlockedError(task_id=tid, blocking_task_ids=blockers)
assert err.code == "TASK_BLOCKED"
assert len(err.details["blocking_task_ids"]) == len(blockers)
def test_task_blocked_error_with_extra_details() -> None:
err = TaskBlockedError(
task_id="t1", blocking_task_ids=["a"], details={"why": "tests"}
)
assert err.details["why"] == "tests"
def test_task_claim_error() -> None:
err = TaskClaimError(task_id="t1", reason="role mismatch")
assert err.code == "TASK_CLAIM_ERROR"
assert err.details["reason"] == "role mismatch"
def test_task_claim_error_with_extra_details() -> None:
err = TaskClaimError(task_id="t1", reason="x", details={"hint": "y"})
assert err.details["hint"] == "y"
def test_agent_error_with_uuid() -> None:
aid = uuid4()
err = AgentError("oops", agent_id=aid)
@@ -235,30 +195,6 @@ def test_agent_error_with_extra_details() -> None:
assert err.details["k"] == "v"
def test_agent_not_available_error() -> None:
err = AgentNotAvailableError(agent_id="a1", status="offline")
assert err.code == "AGENT_NOT_AVAILABLE"
assert err.details["status"] == "offline"
def test_agent_not_available_error_with_details() -> None:
err = AgentNotAvailableError(
agent_id="a1", status="offline", details={"why": "down"}
)
assert err.details["why"] == "down"
def test_agent_busy_error() -> None:
err = AgentBusyError(agent_id="a1", current_task_id="t1")
assert err.code == "AGENT_BUSY"
assert err.details["current_task_id"] == "t1"
def test_agent_busy_error_with_details() -> None:
err = AgentBusyError(agent_id="a1", current_task_id="t1", details={"k": "v"})
assert err.details["k"] == "v"
def test_channel_error_with_uuid() -> None:
cid = uuid4()
err = ChannelError("nope", channel_id=cid)
@@ -319,19 +255,6 @@ def test_notification_error_with_custom_code() -> None:
assert err.code == "CUSTOM"
def test_notification_permission_error() -> None:
err = NotificationPermissionError(agent_id="a1", agent_role="developer")
assert err.code == "NOTIFICATION_PERMISSION_DENIED"
assert err.details["agent_role"] == "developer"
def test_notification_permission_error_with_details() -> None:
err = NotificationPermissionError(
agent_id="a1", agent_role="developer", details={"k": "v"}
)
assert err.details["k"] == "v"
def test_service_error_basic() -> None:
err = ServiceError(service="redis", message="connection refused")
assert err.code == "SERVICE_ERROR"
@@ -354,26 +277,6 @@ def test_database_error_with_details() -> None:
assert err.details["k"] == "v"
def test_llm_error_basic() -> None:
err = LLMError("rate limited", model="claude-haiku-4-5")
assert err.details["model"] == "claude-haiku-4-5"
def test_llm_error_with_details() -> None:
err = LLMError("x", model="m", details={"k": "v"})
assert err.details["k"] == "v"
def test_rag_error_basic() -> None:
err = RAGError("indexing failed", operation="index")
assert err.details["operation"] == "index"
def test_rag_error_with_details() -> None:
err = RAGError("x", operation="search", details={"k": "v"})
assert err.details["k"] == "v"
def test_git_error_basic() -> None:
err = GitError("conflict")
assert err.code == "SERVICE_ERROR"
+126
View File
@@ -0,0 +1,126 @@
"""Guard: no pre-gateway tool names may appear in live ``roboco/`` sources.
The Gateway/full cutover deleted the v1 per-domain MCP tools. Any surviving
reference in a spawn prompt, seed, onboarding string, or comment hands agents
(or future readers) a tool that no longer exists. This test fails if any
reappear.
The orphaned ``roboco/agents/`` subtree is excluded it is pre-gateway dead
code removed wholesale in a later phase, so there is no value in scrubbing its
strings first.
"""
from __future__ import annotations
import pathlib
import re
# Deleted v1 tool names (the gateway replaced them with bare verbs like
# give_me_work / i_am_done / triage / notify / note). roboco_ask_mentor and
# roboco_kb_search are intentionally absent — those are still live.
FORBIDDEN: tuple[str, ...] = (
"roboco_task_scan",
"roboco_task_claim",
"roboco_task_get",
"roboco_task_complete",
"roboco_task_escalate",
"roboco_task_escalate_to_ceo",
"roboco_task_substitute",
"roboco_task_submit_qa",
"roboco_task_submit_verification",
"roboco_task_submit_pm_review",
"roboco_task_qa_pass",
"roboco_task_qa_fail",
"roboco_task_docs_complete",
"roboco_task_create",
"roboco_task_activate",
"roboco_task_cancel",
"roboco_task_plan",
"roboco_task_start",
"roboco_task_progress",
"roboco_task_pause",
"roboco_task_block",
"roboco_task_unblock",
"roboco_agent_idle",
"roboco_escalate",
"roboco_notify_ack",
"roboco_notify_send",
"roboco_notify_list",
"roboco_notify_get",
"roboco_message_send",
"roboco_session_create_for_tasks",
"roboco_journal_decision",
"roboco_journal_learning",
"roboco_journal_struggle",
"roboco_journal_reflect",
"roboco_journal_entry",
)
_ROOT = pathlib.Path(__file__).resolve().parents[2]
_PKG = _ROOT / "roboco"
# Pre-gateway agent subtree, removed wholesale in a later phase.
_EXCLUDED_DIR = _PKG / "agents"
def _excluded(path: pathlib.Path) -> bool:
return _EXCLUDED_DIR in path.parents
def test_no_deleted_tool_names_in_runtime_sources() -> None:
hits: list[str] = []
for path in _PKG.rglob("*.py"):
if _excluded(path):
continue
text = path.read_text(encoding="utf-8", errors="ignore")
for name in FORBIDDEN:
if name in text:
hits.append(f"{path.relative_to(_ROOT)} :: {name}")
assert not hits, "Deleted v1 tool names still referenced:\n" + "\n".join(hits)
# ---------------------------------------------------------------------------
# Hook scripts run at agent runtime and are invisible to the Python import
# graph + mypy. A deleted tool name or a deleted SDK endpoint referenced in a
# hook script breaks silently in the agent container (the traceability-hook
# regression: it kept curling a /traceability/remind endpoint deleted from the
# SDK, 404ing on every gateway tool call). These guards scan docker/scripts/*.sh.
# ---------------------------------------------------------------------------
_HOOK_DIR = _ROOT / "docker" / "scripts"
_SDK_SERVER = _PKG / "agent_sdk" / "server.py"
def test_no_deleted_tool_names_in_hook_scripts() -> None:
hits: list[str] = []
for path in _HOOK_DIR.glob("*.sh"):
text = path.read_text(encoding="utf-8", errors="ignore")
for name in FORBIDDEN:
if name in text:
hits.append(f"{path.relative_to(_ROOT)} :: {name}")
assert not hits, (
"Deleted v1 tool names still referenced in hook scripts:\n" + "\n".join(hits)
)
def test_hook_scripts_curl_only_existing_sdk_endpoints() -> None:
"""Every `$SDK_URL/<path>` a hook curls must be a route still served by the SDK."""
server = _SDK_SERVER.read_text(encoding="utf-8", errors="ignore")
defined = set(
re.findall(
r"""@app\.(?:get|post|put|patch|delete)\(\s*["']([^"'?]+)["']""",
server,
)
)
assert defined, "could not parse any routes from agent_sdk/server.py"
dangling: list[str] = []
for path in _HOOK_DIR.glob("*.sh"):
text = path.read_text(encoding="utf-8", errors="ignore")
# Static path right after $SDK_URL/ — stop at ?, ", whitespace, or $VAR.
for route in re.findall(r"\$SDK_URL/([A-Za-z0-9_/-]+)", text):
if "/" + route not in defined:
dangling.append(f"{path.relative_to(_ROOT)} :: $SDK_URL/{route}")
assert not dangling, (
"Hook scripts curl SDK endpoints that no longer exist in agent_sdk/server.py "
"(remove the hook or restore the endpoint):\n" + "\n".join(dangling)
)