mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
873 lines
30 KiB
Python
873 lines
30 KiB
Python
"""Tier 3 — end-to-end happy paths against the real test DB.
|
|
|
|
Each test exercises the spec → choreographer → TaskService → DB stack
|
|
with Alembic migrations applied. Catches "spec says X, DB constraint
|
|
says Y" mismatches the unit-tier parametrized parity suite cannot
|
|
detect.
|
|
|
|
Companion to ``tests/integration/test_full_lifecycle_real_db.py``
|
|
(audit P2-1 deliverable). That file walks one task through the dev
|
|
chain end to end; this file isolates each major lifecycle path into
|
|
its own test so a regression on, say, QA-fail does not also blow up
|
|
the doc-handoff test.
|
|
|
|
Mocks: only the git layer (workspace + PR ops) is stubbed because the
|
|
test DB has no checkout. The spec, choreographer, VerbRunner, and
|
|
TaskService are real — those are the layers Task 30 verifies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import AsyncMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
from roboco.foundation.policy.lifecycle import Status
|
|
from roboco.models.base import (
|
|
AgentRole,
|
|
AgentStatus,
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
Team,
|
|
)
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
from roboco.services.task import TaskService
|
|
from sqlalchemy import delete
|
|
|
|
# #172: a developer fresh claim must carry a substantive step checklist.
|
|
_STEPS = [
|
|
{
|
|
"title": "Implement the change",
|
|
"description": (
|
|
"edit the target file, add tests, run them, and stage the "
|
|
"change for commit on the task branch"
|
|
),
|
|
}
|
|
]
|
|
|
|
_GOOD_PLAN = (
|
|
"Implement the task on its feature branch: edit the target module, add or "
|
|
"update unit tests covering the change, run the suite locally, then commit "
|
|
"on the branch and open a PR. Keep the diff focused on the acceptance "
|
|
"criteria and verify it before submitting for QA."
|
|
)
|
|
_GOOD_TC = ["Follow the existing module's patterns; keep the change minimal."]
|
|
_GOOD_RISKS = [
|
|
{
|
|
"risk": "Scope creep balloons the diff and slows review.",
|
|
"mitigation": "Touch only the files the acceptance criteria require.",
|
|
}
|
|
]
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
_BRANCH = "feature/backend/healthz"
|
|
_PR_NUMBER = 8
|
|
_PR_URL = "https://github.com/example/life/pull/8"
|
|
# QA notes must clear settings.qa_notes_min_chars (default 80).
|
|
_QA_PASS_NOTES = (
|
|
"Reviewed the diff; route returns 200 OK with timestamp. Tests cover "
|
|
"both acceptance criteria. Approving."
|
|
)
|
|
_QA_FAIL_NOTES_PREFIX = (
|
|
"Reviewed the diff; route returns 500 on the timestamp branch and the "
|
|
"second acceptance criterion is not exercised by the new tests. "
|
|
)
|
|
|
|
|
|
class _StubGit:
|
|
"""Deterministic GitService stub.
|
|
|
|
Mirrors ``test_full_lifecycle_real_db.py``'s _StubGit. Mutates the
|
|
test's TaskTable row directly so the choreographer reads consistent
|
|
pr_number / commits state without disk or network I/O.
|
|
"""
|
|
|
|
def __init__(self, session: Any, task: TaskTable) -> None:
|
|
self._session = session
|
|
self._task = task
|
|
|
|
async def commit(
|
|
self,
|
|
*,
|
|
branch_name: str,
|
|
message: str,
|
|
task_id: UUID,
|
|
files: list[str] | None = None,
|
|
actor_agent_id: Any = None,
|
|
) -> dict[str, Any]:
|
|
del branch_name, files, actor_agent_id
|
|
sha = uuid4().hex[:40]
|
|
commits = list(self._task.commits or [])
|
|
commits.append({"sha": sha, "message": message, "task_id": str(task_id)})
|
|
self._task.commits = commits
|
|
await self._session.flush()
|
|
return {
|
|
"sha": sha,
|
|
"message": message,
|
|
"files_changed": 1,
|
|
"insertions": 1,
|
|
"deletions": 0,
|
|
}
|
|
|
|
async def push_branch(
|
|
self, branch_name: str, *, actor_agent_id: Any = None
|
|
) -> tuple[str, int]:
|
|
del branch_name, actor_agent_id
|
|
return ("ok", 0)
|
|
|
|
async def create_pr(
|
|
self,
|
|
branch_name: str,
|
|
*,
|
|
parent: str,
|
|
is_root_pr: bool,
|
|
actor_agent_id: Any = None,
|
|
) -> dict[str, Any]:
|
|
del branch_name, parent, actor_agent_id
|
|
self._task.pr_number = _PR_NUMBER
|
|
self._task.pr_url = _PR_URL
|
|
self._task.pr_created = True
|
|
await self._session.flush()
|
|
return {"pr_number": _PR_NUMBER, "pr_url": _PR_URL, "is_root_pr": is_root_pr}
|
|
|
|
async def diff(
|
|
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
|
|
) -> str:
|
|
del branch_name, base, actor_agent_id
|
|
return "stub diff"
|
|
|
|
async def list_changed_files(
|
|
self, *, branch_name: str, base: Any = None, actor_agent_id: Any = None
|
|
) -> list[str]:
|
|
del branch_name, base, actor_agent_id
|
|
return []
|
|
|
|
async def pr_target(self, pr_number: int, *, actor_agent_id: Any = None) -> str:
|
|
del pr_number, actor_agent_id
|
|
return "main"
|
|
|
|
async def pr_merge(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
del args, kwargs
|
|
return {
|
|
"merged": True,
|
|
"sha": uuid4().hex[:40],
|
|
"merge_commit_sha": uuid4().hex[:40],
|
|
}
|
|
|
|
|
|
def _mock_evidence_repo() -> Any:
|
|
repo = AsyncMock()
|
|
for method in (
|
|
"list_unread_a2a",
|
|
"list_unread_mentions",
|
|
"list_pending_notifications",
|
|
"task_metadata_gaps",
|
|
"recent_team_activity",
|
|
"blockers_in_lane",
|
|
"journal_highlights_for_task",
|
|
):
|
|
getattr(repo, method).return_value = []
|
|
return repo
|
|
|
|
|
|
def _mock_journal_with_reflect() -> Any:
|
|
"""Journal stub that reports reflect/learning/decision entries present.
|
|
|
|
``latest_decision_at`` is anchored to ``datetime.now(UTC)`` so the C8
|
|
recency window on the PM-decision gate accepts it.
|
|
"""
|
|
journal = AsyncMock()
|
|
journal.has_reflect_for_task.return_value = True
|
|
journal.has_learning_for_task.return_value = True
|
|
journal.has_decision_for_task.return_value = True
|
|
journal.has_struggle_for_task.return_value = False
|
|
journal.latest_decision_at.return_value = datetime.now(UTC)
|
|
return journal
|
|
|
|
|
|
def _mock_work_session() -> Any:
|
|
"""WorkSession stub: stable file list, no unpushed commits."""
|
|
ws = AsyncMock()
|
|
ws.files_changed.return_value = ["roboco/api/routes/health.py"]
|
|
ws.has_unpushed_commits.return_value = False
|
|
return ws
|
|
|
|
|
|
def _build_choreographer(
|
|
db_session: Any, task: TaskTable, task_service: TaskService
|
|
) -> Choreographer:
|
|
"""Wire a real Choreographer with the supplied TaskService + stubbed git.
|
|
|
|
Caller owns the TaskService so it can use it for direct DB reads
|
|
(``task_service.get(task_id)``) — sharing one instance keeps the
|
|
session contract clean and avoids "two TaskServices, two views"
|
|
surprises.
|
|
"""
|
|
deps = ChoreographerDeps(
|
|
task=task_service,
|
|
work_session=_mock_work_session(),
|
|
git=_StubGit(db_session, task),
|
|
a2a=AsyncMock(),
|
|
journal=_mock_journal_with_reflect(),
|
|
audit=AsyncMock(),
|
|
evidence_repo=_mock_evidence_repo(),
|
|
)
|
|
return Choreographer(deps)
|
|
|
|
|
|
async def _seed_agents_and_project(
|
|
db_session: AsyncSession,
|
|
) -> dict[str, Any]:
|
|
"""Seed system + project + dev/qa/doc/cell_pm agents.
|
|
|
|
Slugs match ``agents_config.ESCALATION_CHAIN`` so ``i_am_blocked``
|
|
and ``escalate_up`` find a real escalation target. The agents-config
|
|
chain is the source of truth at runtime; matching it here exercises
|
|
the same lookup the gateway uses in production.
|
|
"""
|
|
system_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="System",
|
|
slug=f"system-{uuid4().hex[:8]}",
|
|
role=AgentRole.SYSTEM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="system",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(system_agent)
|
|
await db_session.flush()
|
|
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="Lifecycle Test Project",
|
|
slug=f"life-{uuid4().hex[:8]}",
|
|
git_url="https://github.com/example/life.git",
|
|
default_branch="main",
|
|
protected_branches=["main"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=system_agent.id,
|
|
is_active=True,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
|
|
dev_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Dev 1",
|
|
slug="be-dev-1",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=["python"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
qa_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE QA",
|
|
slug="be-qa",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=["review"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
doc_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Doc",
|
|
slug="be-doc",
|
|
role=AgentRole.DOCUMENTER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="doc",
|
|
capabilities=["docs"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
cell_pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="BE Cell PM",
|
|
slug="be-pm",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="cell_pm",
|
|
capabilities=["coord"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add_all([dev_agent, qa_agent, doc_agent, cell_pm_agent])
|
|
await db_session.flush()
|
|
|
|
return {
|
|
"system_agent": system_agent,
|
|
"project": project,
|
|
"dev_agent": dev_agent,
|
|
"qa_agent": qa_agent,
|
|
"doc_agent": doc_agent,
|
|
"cell_pm_agent": cell_pm_agent,
|
|
}
|
|
|
|
|
|
def _build_task(
|
|
*,
|
|
project_id: UUID,
|
|
creator_id: UUID,
|
|
assignee_id: UUID | None,
|
|
status: TaskStatus,
|
|
) -> TaskTable:
|
|
"""Construct a backend code-typed task pinned to ``status``.
|
|
|
|
``acceptance_criteria_status`` carries the stub artefact rows the
|
|
pre-merge gate inspects; supplying them here keeps the per-test
|
|
setup readable.
|
|
"""
|
|
return TaskTable(
|
|
id=uuid4(),
|
|
title="Add /healthz endpoint",
|
|
description="Return 200 OK from /healthz",
|
|
status=status,
|
|
priority=2,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
team=Team.BACKEND,
|
|
project_id=project_id,
|
|
created_by=creator_id,
|
|
assigned_to=assignee_id,
|
|
branch_name=_BRANCH,
|
|
acceptance_criteria=["Returns 200", "Includes timestamp"],
|
|
acceptance_criteria_status=[
|
|
{"criterion": "Returns 200", "referencing_artifact_id": "stub"},
|
|
{"criterion": "Includes timestamp", "referencing_artifact_id": "stub"},
|
|
],
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def lifecycle_setup(
|
|
db_session: AsyncSession,
|
|
) -> AsyncIterator[dict[str, Any]]:
|
|
"""Seed agents + project + a single PENDING task assigned to the dev."""
|
|
seeded = await _seed_agents_and_project(db_session)
|
|
task = _build_task(
|
|
project_id=seeded["project"].id,
|
|
creator_id=seeded["system_agent"].id,
|
|
assignee_id=seeded["dev_agent"].id,
|
|
status=TaskStatus.PENDING,
|
|
)
|
|
db_session.add(task)
|
|
await db_session.flush()
|
|
seeded["task"] = task
|
|
yield seeded
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Dev path: pending → claimed → in_progress → verifying → awaiting_qa
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dev_full_chain_through_awaiting_qa(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""pending → claimed → in_progress → verifying → awaiting_qa.
|
|
|
|
Drives ``i_will_work_on`` (claim+plan+start), a stubbed commit,
|
|
``open_pr``, then ``i_am_done`` which auto-runs submit_verification
|
|
+ submit_qa. Asserts the final DB row sits at ``awaiting_qa``.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
task_service = TaskService(db_session)
|
|
stub_git = _StubGit(db_session, task)
|
|
deps = ChoreographerDeps(
|
|
task=task_service,
|
|
work_session=_mock_work_session(),
|
|
git=stub_git,
|
|
a2a=AsyncMock(),
|
|
journal=_mock_journal_with_reflect(),
|
|
audit=AsyncMock(),
|
|
evidence_repo=_mock_evidence_repo(),
|
|
)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.i_will_work_on(
|
|
dev_agent.id,
|
|
task.id,
|
|
plan=_GOOD_PLAN,
|
|
steps=_STEPS,
|
|
technical_considerations=_GOOD_TC,
|
|
risks=_GOOD_RISKS,
|
|
)
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
# Commit + record progress so open_pr's commits-precondition holds.
|
|
await stub_git.commit(
|
|
branch_name=_BRANCH,
|
|
message=f"[{str(task.id)[:8]}] feat(api): add /healthz",
|
|
task_id=task.id,
|
|
)
|
|
await task_service.add_progress(task.id, dev_agent.id, "implemented /healthz")
|
|
|
|
env = await c.open_pr(dev_agent.id, task.id)
|
|
assert env.error is None, f"open_pr failed: {env.message}"
|
|
|
|
env = await c.i_am_done(dev_agent.id, task.id, "tests pass; route works")
|
|
assert env.error is None, f"i_am_done failed: {env.message}"
|
|
assert env.status == Status.AWAITING_QA.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_QA.value
|
|
# i_am_done auto-runs submit_qa, which hands the task off to the
|
|
# backend QA agent (production behaviour — see ``_notify_qa``).
|
|
# Resolve via the same lookup the choreographer uses so the
|
|
# assertion is robust to other test fixtures that may have seeded
|
|
# additional QA agents on the BACKEND team (e.g. smoke_test_batch
|
|
# commits its agents, so they outlive their session).
|
|
resolved_qa = await task_service.qa_agent_for_team(Team.BACKEND)
|
|
assert resolved_qa is not None
|
|
assert final.assigned_to == resolved_qa.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. QA pass path: awaiting_qa → claimed → awaiting_documentation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_pass_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_qa → claim_review → pass_review → awaiting_documentation.
|
|
|
|
``claim_review`` keeps status at AWAITING_QA (specialised qa_claim
|
|
sets assignment without transitioning) so the spec's source-status
|
|
requirement on ``qa_pass`` still matches downstream.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
qa_agent = lifecycle_setup["qa_agent"]
|
|
doc_agent = lifecycle_setup["doc_agent"]
|
|
|
|
# Pin the task at awaiting_qa with a PR + commit so the QA gates
|
|
# (pr exists, commits non-empty) all pass.
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
task.self_verified = True
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_review(qa_agent.id, task.id)
|
|
assert env.error is None, f"claim_review failed: {env.message}"
|
|
after_claim = await task_service.get(task.id)
|
|
assert after_claim is not None
|
|
assert str(after_claim.status) == Status.AWAITING_QA.value
|
|
assert after_claim.assigned_to == qa_agent.id
|
|
|
|
env = await c.pass_review(qa_agent.id, task.id, notes=_QA_PASS_NOTES)
|
|
assert env.error is None, f"pass_review failed: {env.message}"
|
|
assert env.status == Status.AWAITING_DOCUMENTATION.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_DOCUMENTATION.value
|
|
# pass_review reassigns to the team's documenter for handoff. Look
|
|
# up via the same path the choreographer uses (robust to other
|
|
# tests' committed BACKEND documenters).
|
|
resolved_doc = await task_service.documenter_for_team(Team.BACKEND)
|
|
assert resolved_doc is not None
|
|
assert final.assigned_to == resolved_doc.id
|
|
del doc_agent # asserted indirectly via documenter_for_team.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. QA fail path: awaiting_qa → claimed → needs_revision
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_fail_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_qa → claim_review → fail_review(issues) → needs_revision.
|
|
|
|
``fail_review`` reassigns to the original developer so they can
|
|
revise; that lookup walks ``quick_context``'s
|
|
``original_developer:<slug>`` marker, which the dev path stamps
|
|
on i_will_work_on. Here we set it directly so the assertion is
|
|
deterministic without re-running the dev chain.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
qa_agent = lifecycle_setup["qa_agent"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
task.self_verified = True
|
|
# ``extract_original_developer`` parses a UUID off this line; the
|
|
# spec layer's slug-based self-review check is a separate code path
|
|
# (``_extract_original_developer`` in qa.py) which only fires when
|
|
# an actor's slug equals this value, so a UUID here doesn't trip it.
|
|
task.quick_context = f"original_developer:{dev_agent.id}"
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_review(qa_agent.id, task.id)
|
|
assert env.error is None, f"claim_review failed: {env.message}"
|
|
|
|
issues = ["Returns 500 on the timestamp branch", "Missing test for the second AC"]
|
|
# fail_review's notes are derived from issues; QA pass-gate also
|
|
# requires notes >= 80 chars, so we send a leading explanation as
|
|
# the issues list — the verb concatenates them and easily clears
|
|
# the threshold.
|
|
long_issues = [_QA_FAIL_NOTES_PREFIX + issues[0], issues[1]]
|
|
env = await c.fail_review(qa_agent.id, task.id, issues=long_issues)
|
|
assert env.error is None, f"fail_review failed: {env.message}"
|
|
assert env.status == Status.NEEDS_REVISION.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.NEEDS_REVISION.value
|
|
# fail_qa reassigns to the original developer so they can revise.
|
|
assert final.assigned_to == dev_agent.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Doc path: awaiting_documentation → claimed → awaiting_pm_review
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_doc_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_documentation → claim_doc_task → i_documented → awaiting_pm_review.
|
|
|
|
``claim_doc_task`` keeps status at AWAITING_DOCUMENTATION (doc_claim
|
|
is assignment-only, mirroring qa_claim). ``i_documented`` flips to
|
|
AWAITING_PM_REVIEW and reassigns to the cell PM for that team.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
doc_agent = lifecycle_setup["doc_agent"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_DOCUMENTATION
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.assigned_to = None # documenter must claim from unassigned.
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.claim_doc_task(doc_agent.id, task.id)
|
|
assert env.error is None, f"claim_doc_task failed: {env.message}"
|
|
after_claim = await task_service.get(task.id)
|
|
assert after_claim is not None
|
|
assert str(after_claim.status) == Status.AWAITING_DOCUMENTATION.value
|
|
assert after_claim.assigned_to == doc_agent.id
|
|
|
|
env = await c.i_documented(
|
|
doc_agent.id,
|
|
task.id,
|
|
notes="Documented /healthz behaviour in docs/api/health.md",
|
|
files=["docs/api/health.md"],
|
|
)
|
|
assert env.error is None, f"i_documented failed: {env.message}"
|
|
assert env.status == Status.AWAITING_PM_REVIEW.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_PM_REVIEW.value
|
|
# i_documented hands off to the cell PM for the team. Resolve via
|
|
# the same lookup the choreographer uses (robust to other tests'
|
|
# committed BACKEND cell PMs).
|
|
resolved_pm = await task_service.cell_pm_for_team(Team.BACKEND)
|
|
assert resolved_pm is not None
|
|
assert final.assigned_to == resolved_pm.id
|
|
del cell_pm_agent # asserted indirectly via cell_pm_for_team.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. PM complete (Cell PM, simple task): awaiting_pm_review → completed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_complete_simple_task(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_pm_review → cell_pm complete → completed.
|
|
|
|
Post-#178: cell PM completing a non-root awaiting_pm_review task
|
|
always transitions straight to COMPLETED — there is no longer a
|
|
cell→main escalation in ``complete`` (the old branch is gone; the
|
|
cell→main hand-off, when intended, uses ``submit_up``). The
|
|
MAIN_PM deletion below is now a historical artifact (kept because
|
|
other tests in this file rely on the same isolation pattern); it
|
|
no longer affects this test's outcome.
|
|
"""
|
|
await db_session.execute(
|
|
delete(AgentTable).where(AgentTable.role == AgentRole.MAIN_PM)
|
|
)
|
|
await db_session.flush()
|
|
|
|
task = lifecycle_setup["task"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.docs_complete = True
|
|
task.assigned_to = cell_pm_agent.id
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.complete(
|
|
cell_pm_agent.id, task.id, notes="LGTM — merging the leaf PR."
|
|
)
|
|
assert env.error is None, f"complete failed: {env.message}"
|
|
assert env.status == Status.COMPLETED.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.COMPLETED.value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. PM escalate: awaiting_pm_review → awaiting_ceo_approval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pm_escalate_to_ceo_path(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""awaiting_pm_review → main_pm complete on a root task → awaiting_ceo_approval.
|
|
|
|
Main PM completing a root task (no parent) opens the master PR if
|
|
needed and calls ``task.escalate_to_ceo``, leaving the task at
|
|
AWAITING_CEO_APPROVAL with ``assigned_to=None``. CEO approval is
|
|
human-in-the-loop (UI-driven), so the test stops there.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
project = lifecycle_setup["project"]
|
|
system_agent = lifecycle_setup["system_agent"]
|
|
|
|
main_pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="Main PM",
|
|
slug="main-pm",
|
|
role=AgentRole.MAIN_PM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="main_pm",
|
|
capabilities=["coord"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(main_pm_agent)
|
|
await db_session.flush()
|
|
del project, system_agent # only needed for fixture wiring above.
|
|
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.pr_number = _PR_NUMBER
|
|
task.pr_url = _PR_URL
|
|
task.pr_created = True
|
|
task.qa_verified = True
|
|
task.docs_complete = True
|
|
task.parent_task_id = None # explicit — escalate_to_ceo refuses subtasks.
|
|
task.assigned_to = main_pm_agent.id
|
|
task.commits = [
|
|
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
|
]
|
|
await db_session.flush()
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
# SQLAlchemy column-typed `id` attributes need an explicit UUID
|
|
# cast for mypy under the project's strict config — the values are
|
|
# already real ``uuid.UUID`` at runtime.
|
|
env = await c.complete(
|
|
UUID(str(main_pm_agent.id)),
|
|
UUID(str(task.id)),
|
|
notes="Root task ready for CEO approval — escalating.",
|
|
)
|
|
assert env.error is None, f"complete failed: {env.message}"
|
|
assert env.status == Status.AWAITING_CEO_APPROVAL.value
|
|
|
|
final = await task_service.get(task.id)
|
|
assert final is not None
|
|
assert str(final.status) == Status.AWAITING_CEO_APPROVAL.value
|
|
# main_pm_complete clears assigned_to so the orchestrator does not
|
|
# respawn an agent while the task waits on the human CEO.
|
|
assert final.assigned_to is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Block + unblock + restore: in_progress → blocked → in_progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_block_then_unblock_restore(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""in_progress → i_am_blocked → unblock(restore=True) → in_progress.
|
|
|
|
``i_am_blocked`` runs the spec's ``block`` action which delegates
|
|
to ``task_service.escalate``: the task is reassigned to the dev's
|
|
escalation target (``be-pm`` per ``ESCALATION_CHAIN``) and marked
|
|
BLOCKED with the original dev stashed in ``blocker_raised_by``.
|
|
PM ``unblock(restore=True)`` then falls through to the legacy
|
|
``unblock`` path (no ``pre_block_state`` snapshot exists for chain
|
|
escalations), restoring assignment to the dev and flipping back
|
|
to IN_PROGRESS.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
cell_pm_agent = lifecycle_setup["cell_pm_agent"]
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
# Drive into in_progress via the real claim+start sequence.
|
|
env = await c.i_will_work_on(
|
|
dev_agent.id,
|
|
task.id,
|
|
plan=_GOOD_PLAN,
|
|
steps=_STEPS,
|
|
technical_considerations=_GOOD_TC,
|
|
risks=_GOOD_RISKS,
|
|
)
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
env = await c.i_am_blocked(
|
|
dev_agent.id,
|
|
task.id,
|
|
reason="external dependency on auth library upgrade",
|
|
)
|
|
assert env.error is None, f"i_am_blocked failed: {env.message}"
|
|
assert env.status == Status.BLOCKED.value
|
|
|
|
blocked = await task_service.get(task.id)
|
|
assert blocked is not None
|
|
assert str(blocked.status) == Status.BLOCKED.value
|
|
# Escalation reassigns to the cell PM (be-pm) and stashes the dev
|
|
# as blocker_raised_by so unblock can hand the task back.
|
|
assert blocked.assigned_to == cell_pm_agent.id
|
|
assert blocked.blocker_raised_by == dev_agent.id
|
|
|
|
env = await c.unblock(cell_pm_agent.id, task.id, restore=True)
|
|
assert env.error is None, f"unblock failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
restored = await task_service.get(task.id)
|
|
assert restored is not None
|
|
assert str(restored.status) == Status.IN_PROGRESS.value
|
|
# legacy unblock restores assigned_to from blocker_raised_by — the
|
|
# original dev gets the task back so the orchestrator respawns them.
|
|
assert restored.assigned_to == dev_agent.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Pause + resume: in_progress → paused → in_progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_then_resume(
|
|
db_session: AsyncSession, lifecycle_setup: dict[str, Any]
|
|
) -> None:
|
|
"""in_progress → i_am_idle (auto-pause) → resume → in_progress.
|
|
|
|
There is no agent-driven ``pause`` verb. ``i_am_idle`` auto-pauses
|
|
every in_progress task the agent owns so the closure dispatcher can
|
|
wake them on respawn. ``resume`` (composes=("resume",)) then flips
|
|
the same task back to IN_PROGRESS for the same assignee.
|
|
"""
|
|
task = lifecycle_setup["task"]
|
|
dev_agent = lifecycle_setup["dev_agent"]
|
|
|
|
task_service = TaskService(db_session)
|
|
c = _build_choreographer(db_session, task, task_service)
|
|
|
|
env = await c.i_will_work_on(
|
|
dev_agent.id,
|
|
task.id,
|
|
plan=_GOOD_PLAN,
|
|
steps=_STEPS,
|
|
technical_considerations=_GOOD_TC,
|
|
risks=_GOOD_RISKS,
|
|
)
|
|
assert env.error is None, f"i_will_work_on failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
env = await c.i_am_idle(dev_agent.id)
|
|
assert env.error is None, f"i_am_idle failed: {env.message}"
|
|
assert env.status == "idle"
|
|
|
|
paused = await task_service.get(task.id)
|
|
assert paused is not None
|
|
assert str(paused.status) == Status.PAUSED.value
|
|
# Auto-pause keeps assigned_to so resume can find the same claimant.
|
|
assert paused.assigned_to == dev_agent.id
|
|
|
|
env = await c.resume(dev_agent.id, task.id)
|
|
assert env.error is None, f"resume failed: {env.message}"
|
|
assert env.status == Status.IN_PROGRESS.value
|
|
|
|
resumed = await task_service.get(task.id)
|
|
assert resumed is not None
|
|
assert str(resumed.status) == Status.IN_PROGRESS.value
|
|
assert resumed.assigned_to == dev_agent.id
|