From 207aaecd724689e13725b0fa8203693c4613e67d Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Mon, 11 May 2026 02:15:47 +0200 Subject: [PATCH] Feature: lifecycle canonical spec (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: clean make quality baseline on feature/lifecycle-canonical-spec Three classes of pre-existing issues blocking `make quality`: 1. Alembic migrations 002/009/011 used runtime introspection (op.get_bind() + inspect / bind.execute) without guarding for offline (--sql) mode. `alembic upgrade head --sql` is part of `make quality`; in offline mode `op.get_bind()` returns a MockConnection with no inspection system, so the migrations crashed before emitting their SQL stubs. Each migration now short-circuits or simplifies in `context.is_offline_mode()` — live-DB behavior is unchanged. 2. ruff format drift on three files left over from prior in-flight edits (choreographer/_impl.py, content_actions.py, and one test file). `ruff format` applied. 3. vulture flagged two unused `tb` parameters in async __aexit__ stubs in test_task_service_lifecycle_misc.py. The parameter is protocol-required but unused by the body — renamed to `_tb` (vulture treats underscore-prefixed names as intentionally unused). `make quality` is now green from this branch's HEAD; subsequent lifecycle-spec work can use it as the per-task gate. * feat(lifecycle): canonical spec package + Role/Status/TaskType enums Foundation for the canonical lifecycle/permissions module. Enums mirror docs/internal/old/workflows/STATUS_TRANSITIONS.md + PERMISSIONS.md. Tests pin enum membership against both the predecessor canon and roboco.models.base.TaskType. * feat(lifecycle): Decision dataclass with allow/reject/tracing_gap constructors Single rejection shape every consumer maps to its native format (Envelope, HTTP code, prompt hint). __post_init__ enforces the allowed/rejection_kind invariants so a malformed Decision can't reach a consumer. * fix(lifecycle): tighten Decision invariants per Task 2 review Two reviewer findings on the Task 2 Decision dataclass, addressed in one commit: 1. The docstring promised `allowed=True ⇒ rejection_kind is None AND missing == [] AND remediate is None`, but __post_init__ only checked the rejection_kind half. A caller could construct an allow-shaped Decision with stale missing/remediate fields and sneak it past validation. Tighten __post_init__ to enforce the full invariant. Add a regression test. 2. tracing_gap defensively copies the missing list (`list(missing)`) to isolate the stored list from later caller-side mutation, but no test pinned this. Add a regression test that mutates the source list after construction and asserts the stored list is unchanged. Issue 2 from the same review (mutable list vs tuple for `missing`) is a broader design call deferred until consumers exist; the defensive copy is sufficient until then. * feat(lifecycle): Precondition/ActionSpec/IntentSpec/StatusTransition dataclasses The four dataclasses that hold the canonical tables. ActionSpec and StatusTransition are direct ports of pre-gateway PERMISSIONS.md + STATUS_TRANSITIONS.md rows. IntentSpec is the gateway-only addition: each gateway intent verb declares which atomic actions it composes. * feat(lifecycle): _STATUS_TRANSITIONS table + STATUS_GRAPH view Direct port of STATUS_TRANSITIONS.md. Every transition records its trigger action and (optionally) a role constraint. STATUS_GRAPH is the precomputed source→{targets} view callers use for reachability checks. * fix(lifecycle): pin role_constraint values + clarify Task-5 handoff Two reviewer findings on Task 4 _STATUS_TRANSITIONS, addressed in one commit: 1. The original Task-4 tests verified (source, target) pairs but not role_constraint contents. A typo in a single role name (e.g. forgetting MAIN_PM from escalate_to_ceo) would have slipped past them silently. Add test_status_transitions_role_constraints_match_canon pinning every non-None constraint and the cancel-block invariant. 2. role_constraint=None on the `claim` rows from PENDING and NEEDS_REVISION was load-bearing — it is the explicit handoff point between the StatusTransition table (state machine layer) and CLAIM_RULES (per-role claim authority, lands in Task 5). The original inline comment said this in passing; expand it so the design choice is unmissable for a stranger reading just spec.py. * feat(lifecycle): _ATOMIC_ACTIONS + CLAIM_RULES + ROLE_TEAM_RULES tables Direct port of PERMISSIONS.md. Every task management tool gets an ActionSpec with allowed_roles, source_statuses, target_status, self_review_block, and needs_team_match flags. CLAIM_RULES maps each Role to the statuses they can claim from. ROLE_TEAM_RULES is the per-slug team restriction. * fix(lifecycle): tighten ActionSpec contracts per Task 5 review Three reviewer findings on Task 5's _ATOMIC_ACTIONS table, addressed in one commit: 1. set_plan.source_statuses widened to {CLAIMED, IN_PROGRESS} but every existing caller (i_will_work_on / i_will_plan compositions) runs set_plan while CLAIMED, between claim and start. Narrow to {CLAIMED} only. If a future "edit plan mid-flight" feature lands, widen explicitly with test coverage at that time. 2. needs_team_match was set True only on claim/qa_pass/qa_fail/ docs_complete. Defense-in-depth says every role-scoped task action should re-assert team match (don't rely on the inheritance chain through assigned_to alone). Flip to True on: start, set_plan, block, pause, submit_verification, submit_qa, submit_pm_review, complete, create_subtask. Leave False on board/CEO actions and PM cross-cell interventions (unblock, resume, cancel) where the cross-cell semantics are intentional. 3. claim.source_statuses is intentionally a SUPERSET of any single role's CLAIM_RULES allowance (the table holds the union; CLAIM_RULES holds the per-role authority). Add an inline comment above the claim ActionSpec so a future reader doesn't conclude the two tables disagree — they don't, they encode overlapping facts at different grains. * feat(lifecycle): _INTENT_VERBS table — every gateway verb declared Each gateway intent verb is now a named composition of atomic actions plus optional side effects. i_will_work_on = (claim, set_plan, start); i_am_done = (submit_verification, submit_qa); open_pr is pure side effects (push_branch, create_pr); etc. * fix(lifecycle): widen block.allowed_roles to include QA + Documenter Task 6 review caught a role-set inconsistency: i_am_blocked.allowed_roles admits dev/QA/doc, but the underlying block.allowed_roles only allowed dev+PM. Result: a QA or documenter calling i_am_blocked would pass the IntentSpec gate and then be rejected by the composed ActionSpec gate when Task 7 wires can_invoke_intent. Widen block to include QA + Documenter. The semantic case is sound: a QA reviewing a task can discover an external blocker; a documenter writing docs may need PM intervention. Predecessor PERMISSIONS.md restricted block to dev+PM, but with the gateway exposing i_am_blocked to all worker roles, the underlying atomic must agree. The deeper unclaim/escalate_up "imperative verb" concern from the same review (composes=() but mutates state) is deferred to Task 8 where the validator design lands. * feat(lifecycle): public lookup functions + Context + preconditions can_claim, can_invoke_action, can_invoke_intent, valid_next_verbs, composed_actions_for, intents_for_role, status_after — the entire public surface every consumer will use. Context carries the caller-supplied state preconditions need (plan, journal-decision flag, etc.). Preconditions for plan/commits/no_pr/ownership are declared once and wired into the relevant IntentSpecs. * fix(lifecycle): wire PRECONDITION_OWNERSHIP through Context.actor_id Task 7 review found _p_owns_task reads agent.id but every call site passes None for the agent arg. Result: getattr(None, "id", object()) returns a fresh sentinel, task.assigned_to == is always False, and open_pr / i_am_done would reject every owner the moment Task 9 wires consumers. Fix: thread identity through Context.actor_id (new UUID field) and rewrite _p_owns_task to read from the context. Both call sites already pass the Context — no signature changes elsewhere. Add green-path test exercising the owner-can-open-pr case the existing tests missed (the Task 7 plan only tested precondition-failure paths, which masked the bug). Plus surface hygiene: STATUS_GRAPH, CLAIM_RULES, ROLE_TEAM_RULES, and the four PRECONDITION_* constants are now in roboco.lifecycle.__init__.__all__ so consumers in Tasks 8/9 don't depend on the implicit `from roboco.lifecycle.spec import ...` backdoor. * feat(lifecycle): import-time self-consistency validators 10 validators run at module import; first failure raises LifecycleSpecError and prevents the package from loading. Covers status enum coverage, reachability, terminal exits, intent compositions, status chain consistency, claim-rule role/status coverage, self-review symmetry, team-rule slug existence, and StatusTransition action references. * fix(lifecycle): close validator gaps; resolve BACKLOG-claim and submit_qa IN_PROGRESS-shortcut ambiguity Three reviewer follow-ups on Task 8's _validate.py, plus two real data corrections the new action-target-reachability validator surfaced. 1. Design spec §9 calls for "every ActionSpec.target_status, when set, is reachable from each source_status via STATUS_GRAPH" — missing from Task 8's 10 validators. Add _check_action_target_reachable_from_source. 2. _check_role_team_rules_slugs verified slug existence in AGENT_UUIDS but NOT that the cell team in ROLE_TEAM_RULES matches the seed. Add _check_role_team_rules_team_match, scoped to non-None entries only — None means "exempt from team-match enforcement" (cross-cell roles), not "no team in org chart". 3. test_validators_pass_on_real_spec was ceremonial. Add test_run_all_validators_raises_on_unknown_intent_action, a deliberate-break regression that monkeypatches _INTENT_VERBS to inject a fake action and asserts LifecycleSpecError raises. The new action-target-reachability validator caught two real data inconsistencies between the predecessor canon docs and the spec tables: A. claim.source_statuses listed BACKLOG and CLAIM_RULES[*PM] listed BACKLOG, but STATUS_GRAPH[BACKLOG] = {PENDING, CANCELLED} only. Resolution: PMs use the explicit \`activate\` action to move BACKLOG → PENDING, then claim from PENDING. Drop BACKLOG from claim.source_statuses and CLAIM_RULES. B. submit_qa.source_statuses listed IN_PROGRESS, but STATUS_GRAPH[IN_PROGRESS] does NOT include AWAITING_QA. The intent verb i_am_done composes (submit_verification, submit_qa) which forces IN_PROGRESS → VERIFYING → AWAITING_QA — no shortcut. Drop the stale IN_PROGRESS entry from submit_qa.source_statuses. Both corrections tighten the canonical state machine to a strict no-skip transition graph. Pre-gateway PERMISSIONS.md/STATUS_TRANSITIONS.md disagreements are resolved here; spec.py is the canon now. * feat(gateway): Envelope.from_decision maps lifecycle Decisions to envelopes Single shape adapter so verb bodies stop hand-composing rejection envelopes. Each rejection_kind maps to a specific envelope flavor; 'self_review' folds into 'not_authorized' with a parenthetical hint; constructing from an allow Decision raises (programmer error). * feat(gateway): VerbRunner for atomic composed-action dispatch Wraps spec.composed_actions_for(intent) in session.begin_nested() so mid-sequence failures roll the DB back. Side effects run AFTER the savepoint commits. Each atomic action name dispatches to a TaskService method via a single, exhaustive _dispatch_atomic mapping. New verbs slot in by adding an IntentSpec entry + a _dispatch_atomic case if a new atomic is needed. * refactor(gateway): i_will_work_on uses spec.can_invoke_intent + VerbRunner Replace the bespoke status-branch dispatcher in i_will_work_on with the spec-driven flow: load task -> load agent -> build spec.Context -> spec.can_invoke_intent (and spec.can_claim for per-role status authority) -> Envelope.from_decision on rejection -> VerbRunner.run_intent on success. The _i_will_work_on_pending, _i_will_work_on_claimed, _i_will_work_on_needs_revision, and _start_failed_envelope helpers are removed; the runner replaces them. Two narrow verb-body re-entry blocks remain for behaviors the spec does not yet model: 1. in_progress + same agent -> idempotent heartbeat-only return 2. claimed + same agent -> _resume_from_claimed (set_plan + start) to recover from a stuck mid-claim crash without re-running claim against a state the spec excludes. The behavioral claim guards (already_active / paused / sibling_sequence) also stay imperative for now -- they're not in the spec yet and migrate into spec.extra_preconditions in a later task. Per-role claim authority is enforced via spec.can_claim because the atomic claim action's source_statuses are the union across roles; CLAIM_RULES narrows. Parity test in tests/lifecycle/test_consumer_parity.py runs the verb against every (role x status x task_type='code') combo (112 rows) and asserts the envelope error matches the spec's Decision (or can_claim's Decision when the intent gate passes but per-role claim authority does not). This is the contract that makes spec/verb drift impossible. Existing tests updated where rejection-message text changed (the spec now produces the messages, e.g. "role 'cell_pm' may not call 'i_will_work_on'" instead of "PM cannot execute code") or where the spec's stricter view ("invalid_state" -> "not_authorized" for a dev trying to claim awaiting_qa) is more accurate. Test fixtures were updated to wire task.session.begin_nested as a proper async context manager (required by VerbRunner) and to set agent_for().id so runner- driven calls line up with assert_awaited_with(task_id, agent_id). * refactor(lifecycle): push CLAIM_RULES enforcement into can_invoke_action Task 11's i_will_work_on migration had to call spec.can_claim() separately after spec.can_invoke_intent() because the claim action's source_statuses is the union across all claim-eligible roles — can_invoke_intent alone would let a developer pass for claiming awaiting_qa (a QA-only state). The retrofit pattern would repeat in every claim-composing verb (i_will_plan, claim_review, claim_doc_task). Push the per-role narrowing inside can_invoke_action when the action is "claim", using the same not_authorized vs invalid_state disambiguation can_claim already implemented (status-reserved-for-another-role returns not_authorized; status-no-role-can-claim returns invalid_state). Extracted the body to _check_claim_rules_narrow to keep can_invoke_action under xenon's complexity threshold. Update _i_will_work_on_gate to drop the redundant spec.can_claim call. Update test_consumer_parity.py to assert only against can_invoke_intent's Decision. Tasks 12-22 will inherit the cleaner pattern: spec.can_invoke_intent is the single gate; verb bodies don't need per-action retrofits. * refactor(gateway): i_will_plan uses spec.can_invoke_intent + VerbRunner Migrates i_will_plan to the spec-driven pattern Task 11 set up for i_will_work_on. The verb body now: (1) loads task + agent, (2) builds Context, (3) checks idempotent/recovery re-entry, (4) calls spec.can_invoke_intent, (5) returns Envelope.from_decision on rejection, (6) delegates composition to VerbRunner. The _i_will_plan_* helpers are removed — the runner replaces them. Parity test in tests/lifecycle/test_consumer_parity.py runs the verb against every (role × status × task_type) combo and asserts the envelope matches spec.Decision. * refactor(gateway): delegate uses spec.can_invoke_intent for role/state gate Migrates delegate to the spec-driven role/state gate. The chain validation (main_pm->cell_pm, cell_pm->its team's devs), the assignee-vs-task_type rule (Cell PMs receive planning-typed only), the enum coercion, and the parent-lifecycle/cap guards STAY in the verb body — they encode delegate-specific semantics the spec doesn't model. Parity test in tests/lifecycle/test_consumer_parity.py asserts the spec's role+state rejection is correctly surfaced. Chain/assignee rejections continue to be tested in test_choreographer_pm_extras. * refactor(gateway): open_pr uses spec.can_invoke_intent + VerbRunner Migrates open_pr to spec-driven gating. The spec's extra_preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS, PRECONDITION_NO_PR) handle all three precondition checks; the verb body delegates side-effect dispatch (push_branch, create_pr) to VerbRunner. Idempotent re-entry retained: an open_pr call against a task that already has a PR (and the caller owns it) returns OK without re-opening, rather than the tracing_gap the spec would otherwise produce. This preserves agent ergonomics — two calls in a row shouldn't surface a misleading "no_prior_pr" hint. Parity test in tests/lifecycle/test_consumer_parity.py runs the verb against representative (status x commits x pr_number) combos and asserts the envelope matches spec.Decision. * refactor(gateway): i_am_done uses spec.can_invoke_intent + VerbRunner Migrates i_am_done to spec-driven gating. The spec's extra_preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS) handle ownership and commit-count checks; VerbRunner dispatches the (submit_verification, submit_qa) atomic chain. The tracing-gate preconditions (progress entry, journal:reflect, acceptance criteria) and the field-level submit-qa gates stay in the verb body — they model gates the spec doesn't yet cover. Defense-in-depth: those gates run after the spec accepts the ownership/commits checks. Parity test in tests/lifecycle/test_consumer_parity.py runs the verb against (role × status × ownership × commits) and asserts the envelope matches spec.Decision. * refactor(gateway): i_am_blocked uses spec.can_invoke_intent + VerbRunner Migrates i_am_blocked to spec-driven gating. The journal:struggle write stays in the verb body (it's a side effect outside the lifecycle action). VerbRunner dispatches the `block` atomic action via task_service.escalate. Parity test in tests/lifecycle/test_consumer_parity.py. * refactor(gateway): unclaim and resume use spec.can_invoke_intent Migrates both verbs to the spec-driven gate. unclaim's verb body keeps its dispatch (task.unclaim_for_agent) because composes=(); resume goes through VerbRunner with composes=("resume",). The reassignment-rejection branch (introduced in 19f27b4 for the 2026-05-08 trace's "not your claim" case) stays - the spec doesn't model "task got reassigned out from under you by an upstream verb," and the existing envelope text ("current owner: X - call give_me_work() to find your current work") is the load-bearing hint that fixed the original bug. Extracted the shared branch into _reassigned_rejection / _ReassignedCtx so both verbs reuse it without duplicating the envelope construction. Parity tests in tests/lifecycle/test_consumer_parity.py. * refactor(gateway): complete uses spec.can_invoke_intent at the dispatcher Migrates the top-level `complete` dispatcher to gate role/state via spec.can_invoke_intent before routing to cell_pm_complete or main_pm_complete. The two lower-level methods keep their existing PR-merge / CEO-escalation logic and pre-flight guards (those model journal:decision preconditions and PR-mergeability checks the spec doesn't model yet). The runner pattern is NOT applied here — `complete` has two divergent runtime paths (Cell PM merges leaf into parent branch; Main PM opens master PR + escalates to CEO) that don't fit the runner's single-composition model. Verb-body-owns-dispatch is the right pattern. Parity test in tests/lifecycle/test_consumer_parity.py runs the verb against (role × status) combos and asserts the dispatcher's spec rejection is correctly surfaced. * refactor(gateway): escalate_up, escalate_to_ceo, submit_up use spec.can_invoke_intent Migrates the three PM-side escalation/submission verbs to spec-driven role/state gating. The verb-specific guards (journal:decision, escalation_target configured, _submit_up_guard's ownership + notes-length + subtasks-terminal) STAY in the verb body - the spec doesn't model these. escalate_up has composes=() so the verb body owns dispatch via task.escalate. escalate_to_ceo and submit_up route their compositions through VerbRunner. Parity tests in tests/lifecycle/test_consumer_parity.py. * refactor(gateway): qa.py + doc.py role mixins use spec.can_invoke_intent Migrates the five QA + Documenter verbs (claim_review, pass_review, fail_review, claim_doc_task, i_documented) to spec-driven gating. The self-review block lives at the atomic-action layer (_ATOMIC_ACTIONS["qa_pass"|"qa_fail"|"docs_complete"].self_review_block=True) and naturally fires when the verb body builds a Context with actor_slug==original_developer_slug. No verb-body retrofits needed. The verb-specific helpers (_verify_qa_owner, _qa_pass_gate_check, _check_i_documented_inputs) STAY — they encode notes-length / journal:learning / files-list / qa_evidence_inspected gates the spec doesn't model. claim_review and claim_doc_task own dispatch via task.qa_claim / task.doc_claim respectively (not the runner) because those specialized claim methods keep status at AWAITING_QA / AWAITING_DOCUMENTATION, which is what the downstream qa_pass / qa_fail / docs_complete source-status requirement expects. The spec gate still validates role + claim source-status + task_type before dispatch. pass_review / fail_review / i_documented route their compositions (qa_pass / qa_fail / docs_complete) through VerbRunner.run_intent inside a savepoint. Parity tests in tests/lifecycle/test_consumer_parity.py for all five verbs. * fix(lifecycle): claim_review and claim_doc_task have empty composes Tasks 21-22 surfaced a real spec/runtime mismatch: both verbs were declared composes=("claim", "start"), but the actual implementation uses task.qa_claim / task.doc_claim which intentionally keep status at AWAITING_QA / AWAITING_DOCUMENTATION. If the runner ever ran the declared composition, it would transition the task to CLAIMED then IN_PROGRESS, breaking the source-status invariants of qa_pass, qa_fail, and docs_complete. The spec is the canon — align it to the runtime. composes=() means "verb body owns dispatch" (same pattern as escalate_up and unclaim). The spec gate still validates role + AWAITING_QA / AWAITING_ DOCUMENTATION source-status via the role's CLAIM_RULES narrowing, enforced through special handling in can_invoke_intent, so role/state safety is preserved. * refactor(gateway): role_config flow lists derived from spec.intents_for_role Hand-maintained _DEV_FLOW etc. tuples replaced with calls into the spec. Adding/removing a role from an IntentSpec.allowed_roles now automatically updates the MCP manifest. The spec is the canon; role_config becomes a thin shim that adds the do-tool / write / subagent / description metadata the spec doesn't carry. * feat(lifecycle): generators + make lifecycle for deterministic artifact regen Renders intent-verbs.md, status-transitions.md, panel/lib/lifecycle.json, and per-role agents/prompts/_generated/lifecycle-{role}.md fragments from the canonical spec. `make lifecycle` runs the regenerator; deterministic output enables CI to gate on `git diff --exit-code` after running it. The agent prompt fragments will be injected at the top of each role's system prompt (Task 25) so agents see the same verbs the gateway accepts. * feat(lifecycle): inject generated prompt fragments + CI drift gate Each agent's system prompt now starts with the spec-generated 'verbs available to your role' fragment. CI runs make lifecycle and fails if regeneration produces a diff — drift between spec and artifacts cannot land on master. * refactor(gateway): delete verb_gates.py — superseded by lifecycle.spec verb_gates.is_verb_allowed and verb_gates.valid_next_verbs are now spec.can_invoke_intent(...).allowed and spec.valid_next_verbs. Importers updated to consume the canonical spec module directly. tests/unit/gateway/test_verb_gates.py removed — coverage lives in tests/lifecycle/test_spec.py. envelope.with_introspection wraps spec.valid_next_verbs with role-string coercion + best-effort try/except so malformed task fixtures (AsyncMock status) and unknown role strings still yield [] instead of raising — preserves the legacy verb_gates contract. content_actions content-tool RBAC (commit/notify) is now a pair of explicit role frozensets in this file. These are content tools, not lifecycle intents, so they intentionally do NOT live in spec._INTENT_VERBS. Two existing introspection tests asserted "commit" in valid_next_verbs; fixed to assert open_pr/i_am_done — commit is correctly absent under the canonical spec because it is a do-server content tool, not a flow intent verb. * refactor(gateway): collapse scattered role constants into spec The pm_cannot_execute_code_guard and role_typed_claim_guard guards both modeled rules the spec now handles via can_invoke_action's CLAIM_RULES narrowing and ActionSpec.allowed_task_types. Drop them from claim_guards.py — the choreographer's existing skip-flags on _run_claim_guards are now permanent: those guards no longer fire. Simplify _run_claim_guards's signature accordingly. The concurrency-invariant guards (already_active_guard, paused_tasks_guard, sibling_sequence_guard) STAY — the spec doesn't model these system-level invariants. sibling_sequence_guard's loop body extracted into _earlier_blocking_sibling helper to keep the slimmed module under xenon's --max-modules A average. * refactor(enforcement): task_lifecycle becomes a thin view of lifecycle.spec VALID_TRANSITIONS and ROLE_RESTRICTED_TRANSITIONS are now derived from roboco.lifecycle.spec — no independent tables. The 433-line file collapses to ~30 lines of view definitions; future changes go in spec.py. Helper functions exported by the legacy module are preserved as thin wrappers so existing consumers don't need to change their imports today. A small _LEGACY_OPERATIONAL_EDGES table sits alongside the spec-derived view to cover transitions the runtime exercises but the spec has not yet absorbed (voluntary unclaim, reaper sweep, PM-direct completes from in_progress, parallel-doc-PR developer trigger). It is fenced and clearly documented; once those callers are migrated to spec-driven dispatch the constant goes empty and the file collapses to a pure view. A test in test_task_service_lifecycle_misc.py was rewritten: the predecessor asserted CEO-only authority over awaiting_ceo_approval cancels (legacy table behavior), but the canonical spec authorizes {CELL_PM, MAIN_PM, CEO} uniformly across all non-terminal cancel sources. The test now exercises the broader spec-defined cascade. * feat(lifecycle): UNMIGRATED guard pins known-debt consumers Two pieces of debt surfaced during Task 28's collapse of enforcement/task_lifecycle.py: (1) ~11 operational edges still in the shim's _LEGACY_OPERATIONAL_EDGES because the spec's _STATUS_TRANSITIONS doesn't yet model them; (2) role-gate disagreements in _LEGACY_ROLE_GATES that the spec disagrees with. UNMIGRATED is the named-debt set; KNOWN_UNMIGRATED_CONSUMERS pins the catalog so a contributor adding a new entry must update both sides. Validator (_check_unmigrated_is_subset) fires at import if they drift. Test pins the current entries. Phase 3's terminal invariant is `UNMIGRATED == frozenset()` — expected when both legacy data carriers fold into spec, at which point the assertion becomes a permanent regression guard. * test(lifecycle): tier 3 end-to-end real-DB happy paths Eight integration tests covering every major lifecycle path: dev (pending → awaiting_qa), QA pass, QA fail, doc handoff, Cell PM complete, Main PM escalate-to-CEO, block+unblock, pause+resume. Each test drives the spec → choreographer → TaskService → DB stack with only the git layer mocked. Catches "spec says X, DB constraint says Y" mismatches the unit-tier parametrized parity suite cannot detect. * test(lifecycle): tier 4 smoke replay — pin known-bug shapes after spec migration Synthesized fixture covering the 9 bugs from the 2026-05-08 audit-log trace + the 2 from the 2026-05-09 follow-up trace. Each record documents (verb, role, task setup, expected post-fix envelope shape, fix commit, spec invariant). The replay test parametrizes over the records and asserts the spec / choreographer behavior now matches the post-fix expectation — locks in the fixes as permanent regressions. The original audit log was wiped during cleanup; the fixture is a documented synthesis, not a verbatim capture. The bug list is faithful to the prior session's analysis of the trace. * fix(orchestrator): silence dev-dispatcher noise for non-dev-lane tasks Dev dispatcher fetched all pending/claimed/in_progress tasks regardless of assignee role and warned 'role/task_type mismatch' on each pass when it found cell_pm/main_pm/product_owner/etc. tasks — those belong to _dispatch_pm_work, not this lane. The 30s warning loop showed up prominently in the 2026-05-10 smoke run. Filter at the lane boundary: silently skip when assignee role is not developer/documenter/unknown. The D-49 misassignment warning still fires for the legitimate cases (developer assigned a documentation task, etc.). * fix(gateway,prompts): unblock the three smoke-run dead-ends Three issues surfaced by the 2026-05-10 smoke run, fixed together because they're all blockers for end-to-end task completion: 1. Acceptance-criteria tracing gate was unsatisfiable. Nothing in the codebase writes to task.acceptance_criteria_status, so _check_acceptance_criteria always returned every criterion as missing. Treat a reflect note as the addressing artifact: when the agent has written one, the gate clears. Per-criterion citation via acceptance_criteria_status is still honored when populated, so the schema stays available for future per-criterion tracking. 2. Cell PM runaway re-decomposition. On every wake-up be-pm re-decomposed its parent task without checking for existing children, producing duplicate dev subtasks. cell_pm.md now teaches 'list children before delegating' and 'one dev subtask is usually enough — QA/Documenter/PM-merge engage automatically'. Added anti-pattern entries for re-decomposition and over-decomposition. 3. Main PM exit/respawn loop on claimed-state tasks. The model cycled through delegate/resume/escalate/unblock looking for a verb that worked on 'claimed', and got cleanly rejected by every one. The right verb is i_will_plan (it composes claim+set_plan+start and resumes from claimed). main_pm.md now spells this out explicitly with a worked example of which verbs reject and why. * feat(prompts): restore pre-gateway lifecycle scaffolding across all 6 roles The gateway migration shrank role prompts from ~50 lines to ~15 (commit 534152c for dev; analogous shrinks for qa/doc/cell_pm/main_pm/board in e12a596, 05ac832, 8dc381b). The verb surface got cleaner but the prescription for using verbs through the lifecycle disappeared. The 2026-05-10 smoke run surfaced the regression: agents thrash through verbs hoping one fits, journal sparsely, skip the dev reflect note, and (for cell PMs) re-decompose on every wake-up. Each role prompt now restores three sections that the pre-gateway versions had: 1. State -> Verb table — what to call when respawned in each lifecycle status. Eliminates the verb-cycling antipattern: the agent looks up its current status and calls the one verb that transitions out of it. 2. Mandatory pre-handoff checklist — explicit walk-through of the gates the next verb will check, ordered so the agent fixes the missing piece before retrying: - developer: 7 items before i_am_done - qa: 8 items before pass/fail (incl. self-review forbidden, read dev journal not just diff, name artifact per criterion) - doc: 7 items before i_documented - cell_pm: 7 items before submit_up (incl. integration green) - main_pm: 7 items before complete(root) - board: separate checklists for escalate_to_ceo (PO/HoM) and reflect-note quality (Auditor — its only output) 3. Journaling cadence — when to use each of the five scopes (note/decision/struggle/learning/reflect). The pre-gateway prompts named all five scopes with role-specific examples; the post-gateway prompts mention 'reflect' once and skip the rest. Restored across every role. Plus restored the load-bearing rules that got dropped: - Cell PM: 'A SINGLE subtask flows through dev -> QA -> doc -> PM-merge. DON'T split into per-role subtasks.' This is exactly what be-pm violated in the smoke run, creating duplicate 'branch naming subtask' / 'PR workflow subtask' / etc. - QA + Doc: 'read the dev's journal, not just the diff' — pre- gateway forced this via roboco_journal_read_team; post-gateway the inline data exists but the agent isn't told to use it. - Developer: 'every acceptance criterion gets a citation in the reflect note' — pairs with the tracing-gate change in 75b667d where the reflect note is treated as the addressing artifact. * feat(foundation): bootstrap foundation/identity.py with Role/Team/RoleLevel Phase 1 task 1 of the foundation canonicalization plan (docs/superpowers/specs/2026-05-10-foundation-canonicalization-design.md). Three enums, no consumers yet — separate tasks migrate the existing forks (models.base.AgentRole, lifecycle.spec.Role, agents_config role sets, services/permissions.PM_ROLES) onto this canonical surface. * feat(foundation/identity): add AGENTS catalog (single source for slug->role+team+UUID) Resolves head-marketing.team drift (spec §5.1) by setting Team.BOARD authoritatively. Team.MARKETING remains in the enum for legacy seed data but no agent claims it; flagged for removal in cleanup. * feat(foundation/identity): add role-sets + ROLE_LEVEL hierarchy * feat(foundation/identity): add lookups + public API re-exports * feat(foundation): import-time validators (uniqueness, role coverage, role-level) * chore(foundation): verify+align postgres agentrole/team enums with foundation/identity scripts/verify_postgres_enums.py reads the live agentrole+team enums from postgres (via asyncpg using roboco.config.settings.database_*) and compares them against the foundation Role+Team enums. Exits 0 on match, 1 on drift (with a per-side diff), and 1 with a clear message if postgres is unreachable so callers like make foundation-check can treat that as a skip. alembic/versions/012_align_agentrole_team_with_foundation.py is the forward-only safety-net migration. It runs ALTER TYPE agentrole ADD VALUE IF NOT EXISTS 'system' (idempotent on postgres >= 9.6) so any DB without the recently-added Role.SYSTEM sentinel gets it on next upgrade. Postgres has no DROP VALUE primitive without a destructive type recreation, so foundation keeps legacy values (e.g. Team.MARKETING) to absorb the inverse direction; the migration's downgrade is intentionally a no-op. Local verification deferred: postgres is not reachable from this workstation (role 'roboco' does not exist), so the script could not confirm the live enum shape. The migration is idempotent and runs unconditionally on the next alembic upgrade head, and whoever next runs make foundation-check against a live DB will get the post-migration proof of alignment. * refactor(lifecycle): re-export Role from foundation.identity (single source) * refactor(models): re-export AgentRole and Team from foundation.identity Removes the parallel Team and AgentRole StrEnum definitions in models/base.py. They are now bound to roboco.foundation.identity.Role and roboco.foundation.identity.Team respectively, so AgentRole IS identity.Role (same Python class object). SQLAlchemy column types bound as sa.Enum(AgentRole, name='agentrole') continue to work because identity is preserved across import paths. Note: foundation.Team drops the legacy 'fullstack' member that lived on models.base.Team. The two _resolve_team_dir tests that used Team.FULLSTACK to exercise the 'fullstack' branch now pass the literal string 'fullstack' instead — same code path, no enum-membership coupling. Adds two identity assertions to tests/foundation/test_role_reexport.py verifying AgentRole is identity.Role and Team is identity.Team. * fix(foundation): correct Team enum — add FULLSTACK, remove QA The original plan's audit incorrectly identified the models/base.Team membership. Actual original was 7 values: backend, frontend, ux_ui, fullstack, main_pm, board, marketing. My plan replaced fullstack with qa and added system — but qa was never a team (only a role). Postgres team enum has fullstack (alembic 009), and services/task.py:675 + services/git.py:779 branch on the literal "fullstack". Without foundation.Team.FULLSTACK, any Project row with assigned_cell="fullstack" would fail to round-trip through the SQLAlchemy ORM. This correction: - Adds FULLSTACK; removes QA from foundation.Team - Updates the 8-value test expected set - Restores tests/integration/test_task_service_misc.py to use Team.FULLSTACK * refactor(agents_config): derive AGENT_ROLE_MAP/AGENT_TEAM_MAP/CELL_MEMBERS from foundation * refactor(roles): canonicalize role-sets via foundation.identity - agents_config.PM_ROLES (5-role: PMs + board + CEO) renamed to TASK_CREATOR_ROLES; the name PM_ROLES is reserved for the canonical 2-role set (CELL_PM + MAIN_PM) defined in foundation.identity. - agents_config._BOARD_ROLES aliased to foundation.BOARD_ROLES (drops main_pm from the set; board A2A handler updated to keep allowing board -> main_pm direct messaging via explicit branch). - services/permissions.PM_ROLES (2-role) re-exported from foundation. Closes the silent semantic divergence flagged in spec section 3 (HIGH severity). * refactor(seeds,orchestrator): derive agent catalogs from foundation - seeds/initial_data.AGENT_UUIDS derived from foundation.AGENTS. - DEFAULT_AGENTS row generation pulls slug+role+team+id from foundation; per-agent presentation strings (display name) stay in this file in _AGENT_PRESENTATION dict. The system sentinel remains a literal with team=None because the postgres `team` enum has no 'system' value. - runtime/orchestrator._AGENT_TEAM_MAP and the cell-prefix table replaced with foundation.team_for_slug. _AGENT_TEAM_MAP is now a derived ClassVar covering every slug (not just management). - head-marketing.team resolved to "board" (was "marketing" in seed + orchestrator, "board" in agents_config — three-way drift, now unified). - ceo.team resolved to "board" (was None in seed; foundation declares board membership so the seed-bootstrapped DB row now reflects that). - Adds tests/foundation/test_seed_orchestrator_parity.py — gate against future drift between seed/orchestrator and foundation. Closes the identity sub-phase. Adding an agent edits exactly one file: foundation/identity.py:AGENTS. * feat(foundation/policy): task_completeness rules + denylist Implements spec §5.2: field-level completeness rules at create/delegate time, plus the denylist that catches the literal placeholder string from the deleted services/task.py:5061-5062 silent fallback ("completed and reviewed by assignee" — agents copy-paste this from old logs). CompletenessSpec is data; check() is a pure function; field_hints map gives the agent the literal answer key for each missing field. * feat(envelope): add incomplete_input envelope kind for interrogation pattern Sister to tracing_gap; distinct error code lets agent prompts teach incomplete_input handling separately from tracing-gap recovery. Carries missing + field_hints + remediate for the spec §5.2.1 interrogation pattern; Task 19 will wire the gateway delegate verb to use it. * feat(foundation/task_completeness): auto-fill helpers (team, priority, parent) * feat(api/schemas): DelegateRequest enforces TASK_AT_CREATE constraints Removes silent defaults for nature/task_type/estimated_complexity; adds min_length=20 to description; requires non-empty acceptance_criteria. Mirrors foundation.policy.task_completeness.TASK_AT_CREATE so under-filled delegate calls fail at the request boundary (422) instead of being silently papered over downstream. Tests touching delegate calls updated to pass the now-required fields. * feat(models/task): TaskCreate + TaskCreateRequest enforce TASK_AT_CREATE * feat(api/schemas): TaskUpdate rejects blanking acceptance_criteria Golden Rule preservation — acceptance_criteria cannot be set to []/None via PATCH. Pydantic field min_length doesn't catch explicit None, so a model_validator(mode='before') guards the patch payload. * fix(services/task): delete silent acceptance_criteria fallback (skeleton-task root cause) The fallback at services/task.py:5061-5062 silently replaced empty acceptance_criteria with ['completed and reviewed by assignee'] - the proximate cause of every skeleton task in the 2026-05-10 smoke run. Removed; create_subtask now invokes foundation.policy.task_completeness and raises TaskCompletenessError on missing fields (spec section 5.2). Two existing transition tests relied on a 1-char description default that the new completeness check rejects (description min_length=20); both updated to pass an explicit valid description. New integration test pins the rejection contract (empty list + legacy phrase both raise). Companion code path at services/gateway/choreographer/_impl.py:1852 (the upstream `or []` collapse) is fixed in the next task. * fix(gateway/delegate): use task_completeness + Envelope.incomplete_input Replaces the `acceptance_criteria=inputs.acceptance_criteria or []` collapse at _impl.py:1852 with a foundation.policy.task_completeness check that rejects empty / placeholder input via Envelope.incomplete_input — the spec section 5.2.1 interrogation pattern. Auto-fill helpers (fill_team_from_assignee + fill_priority_from_parent) fill the unambiguous fields before the check, then anything still missing surfaces as a structured rejection with field_hints; the agent gets a literal answer key for what to provide on retry. DelegateInputs gains an explicit `nature` field (no default) so the HTTP boundary can thread DelegateRequest.nature through to the choreographer. Route handlers (flow_cell_pm, flow_main_pm) forward it. The hardcoded TaskNature.TECHNICAL fallback in _create_subtask_from_inputs is removed; the helper now coerces inputs.nature to the enum or raises TaskCompletenessError if a non-gateway caller bypassed the check. Closes the gateway-side path to skeleton tasks. Service-layer raise (Task 18) remains as defense-in-depth for non-gateway callers. Existing delegate-guard tests updated to pass full payloads — the prior `title='x', description='y'` minimal stubs now hit the completeness gate first; the full payloads still exercise the auth/chain/cap guards downstream. * feat(api/routes/tasks): POST /tasks uses foundation.task_completeness check Replace the hand-rolled acceptance_criteria non-empty check in the POST /tasks handler with a call to task_completeness.check(TASK_AT_CREATE, data). Route, schema (TaskCreate), and service (TaskCreateRequest) now all share one canonical notion of 'complete' — the fourth and final create path is now strict. Pydantic still rejects structurally invalid payloads (empty AC list, short title/description, missing enums) with 422. The TC check at the route boundary additionally rejects denylisted placeholder phrases ('completed and reviewed by assignee', etc.) that pass schema validation but signal a stub task. Add tests/integration/test_post_tasks_completeness.py: - empty acceptance_criteria -> 422 (Pydantic) - placeholder phrase -> 400/422 with 'acceptance_criteria' in body * chore(make): add foundation-check drift gate (mirrors lifecycle-check) * test(foundation): Phase 1 smoke gate — skeleton-task path returns incomplete_input Phase 1 closes here: identity catalogs are single-sourced; the silent acceptance_criteria fallback is gone; gateway delegate returns incomplete_input with populated field_hints when criteria are missing. The 2026-05-10 smoke run that produced skeleton tasks no longer can. Phases 2-4 (tracing, journaling, communications, agent_loop, housekeeping) get their own plans. * feat(foundation/policy): journaling scope catalog (5 panel-UI scopes) * feat(foundation/policy/journaling): role read tiers + protected journals * refactor(content_actions): derive _VALID_NOTE_SCOPES from foundation.journaling * refactor(services/journal): derive _SCOPE_TO_TYPE from foundation.journaling * refactor(enforcement/journal_perms): import read-tier rules from foundation PROTECTED_JOURNALS + ROLE_READ_TIERS now sourced from foundation.policy.journaling. The local helpers (_check_protected_access, _check_cell_pm_access, _check_cell_member_access) are collapsed into a single tier-driven check via _decide_protected / _decide_by_tier. Pre-Phase-2 GLOBAL_READERS that lumped CEO/auditor/PO/HoM/main_pm together is split into ReadTier.ALL (ceo+auditor — includes protected) vs ReadTier.ALL_CELLS (others — excludes protected). Observable behavior preserved. * feat(foundation/policy): tracing Requirement enum + check_requirements 19 requirements (16 from pre-Phase-2 tracing_gate + 3 pre-gateway parity: JOURNAL_NOTE_AT_CLAIM, JOURNAL_DECISION_AT_CLAIM, JOURNAL_DURING_WORK). GateContext expanded with the new presence flags and journal_during_work_count. Acceptance-criteria checker keeps the spec §9 item 1 reflect-note shortcut. * feat(foundation/policy/tracing): VERB_REQUIREMENTS table + verb parity validator Maps every gateway intent verb to its required-set. Includes the 6 inline journal:decision callsites (submit_up, complete, unblock, escalate_up, escalate_to_ceo, delegate) plus the 4 pre-gateway parity additions (NOTE_AT_CLAIM, DECISION_AT_CLAIM, REFLECT on complete, DURING_WORK). Validator asserts every spec verb is covered or explicitly waived, and every Requirement enum value is used by at least one verb. PLAN added to i_will_work_on / i_will_plan (mirrors spec.PRECONDITION_PLAN in the tracing layer). SELF_VERIFIED added to i_am_done as a defense-in-depth backstop (auto-set by the in_progress→verifying transition). * refactor(gateway/i_am_done): tracing gates via foundation.policy.tracing Adds JOURNAL_DURING_WORK_AT_LEAST_ONE check (pre-gateway parity P2 — agents must write at least one decision/learning/struggle entry between claim and submit). Adds journal.has_struggle_for_task helper. Replaces the pre-Phase-2 tracing_gate.check_requirements call. SELF_VERIFIED is filtered from the pre-flight required-set: the spec composes (submit_verification, submit_qa) for i_am_done and the auto-run submit_verification flips self_verified=True before submit_qa runs. The flag therefore acts as a defense-in-depth backstop AFTER the spec, not before — checking it pre-flight would block the auto-verify path. SELF_VERIFIED stays in the foundation required-set and is re-asserted by the spec action's own preconditions. Test fixtures updated: 9 i_am_done success-path tests now mock has_decision_for_task=True (or equivalent) so the new during-work cadence gate is satisfied. NO_PR-token assertion broadened to also accept the foundation token "pr_open". * refactor(gateway/qa): pass/fail gates via foundation.policy.tracing * refactor(gateway/doc): i_documented gates via foundation.policy.tracing Doc-specific missing-key translations (docs_notes>=min, docs_files_non_empty) added to the central _build_tracing_gap translator established in Task 9. * refactor(gateway): unify 6 inline journal:decision checks via tracing.check_requirements Pre-Phase-2 inline blocks at _impl.py lines ~2230/2394/2442/2574/2814/2895 each ran the same has_decision_for_task + Envelope.tracing_gap pattern. They now call: - _check_pm_decision_required(verb, ...) — for unblock, escalate_up, escalate_to_ceo, delegate. Each declares only JOURNAL_DECISION in VERB_REQUIREMENTS, so a single helper consuming tracing.requirements_for(verb) suffices. - _check_complete_gates — for cell_pm_complete and main_pm_complete. Consumes VERB_REQUIREMENTS["complete"] = JOURNAL_DECISION + JOURNAL_REFLECT + NOTES_MIN_CHARS. The inline _subtasks_not_terminal_envelope is kept because its remediation enumerates the non-terminal subtask ids — strictly richer than the foundation hint. - _check_submit_up_gates — for submit_up. Consumes VERB_REQUIREMENTS["submit_up"] minus SUBTASKS_TERMINAL (deferred to the inline envelope for the same reason as complete). Also adds the journal:decision tracing gate to the delegate verb (VERB_REQUIREMENTS["delegate"] = {JOURNAL_DECISION}) — pre-gateway PM.md required journal:decision before each delegate, but the gateway path had not yet enforced it. Threaded into _delegate_extra_guards so the verb body's return count stays under the lint cap. _build_tracing_gap gains hint translations for journal:decision, notes>=min, and subtasks_terminal. The body is refactored to a static dispatch table + acceptance-criteria batch handler so the branch count stays under the lint cap. PM-verb success-path tests updated to provide notes >= 20 chars (the new NOTES_MIN_CHARS gate); has_reflect_for_task mocks added to a few tests where they're now load-bearing (AsyncMock truthiness covers most). 6575 tests passing, mypy + ruff clean. * feat(gateway/claim): require journal:note_at_claim and journal:decision_at_claim Pre-gateway parity P1, P3: developers wrote a note (scope='note') on every claim; PMs wrote a decision (scope='decision') on plan. Restored via foundation.policy.tracing requirements wired through a new _post_claim_journal_gate helper that runs AFTER the composed (claim, set_plan, start) sequence completes. Failed checks return tracing_gap with a remediate hint that tells the agent to journal then retry. The claim itself stays — the agent journals and re-issues the verb (idempotent re-entry shortcuts back to OK once the entry is present). Adds journal.has_note_for_task helper paralleling has_decision/reflect/learning/struggle. The PLAN requirement is filtered out of the post-claim check because spec.PRECONDITION_PLAN already enforced it before the runner ran — re-asserting at the tracing layer would emit a misleading hint. Two new tests verify the gate fires for missing note/decision; existing success-path tests already mock the journal service via AsyncMock (returning truthy) so no regressions. * test(foundation): Phase 2 smoke gate + tracing_gate.py deleted Phase 2 closes here: - foundation/policy/journaling.py owns the 5-scope catalog + read tiers - foundation/policy/tracing.py owns Requirement enum + VERB_REQUIREMENTS - 6 inline journal:decision checks replaced with unified helpers - pre-gateway parity restored: NOTE_AT_CLAIM, DECISION_AT_CLAIM, DURING_WORK, REFLECT-on-complete - services/gateway/tracing_gate.py deleted - enforcement/journal_perms.py read-tier rules canonicalized Smoke gate 2 enforces: no inline has_decision_for_task remains; every intent verb has a tracing decision; tracing_gate module is gone. * fix(foundation/task_completeness): align hint strings with actual enum values _HINT_NATURE listed 5 values (technical | bugfix | feature | refactor | docs) but TaskNature only has 2 (TECHNICAL / NON_TECHNICAL). _HINT_ESTIMATED_COMPLEXITY listed "critical" which Complexity doesn't have. _HINT_TEAM omitted FULLSTACK (real, used) and didn't note that MARKETING is legacy seed-data. _HINT_TASK_TYPE was already correct. Hints now reflect the actual enums in roboco/models/base.py and roboco/foundation/identity.py — agents reading the gateway's incomplete_input remediate envelopes will no longer be told to send values the enums reject. Tests using nature="feature" (DelegateRequest's nature is `str`, not the enum, so it accepted the fake value silently) updated to nature="technical" so they exercise a real enum value end-to-end. * fix(orchestrator): remove dead "critical" complexity branches Complexity enum has only LOW / MEDIUM / HIGH — no CRITICAL value. The three "critical" branches in dispatch logic at lines ~3032 / 3331 / 5049 were dead code (the comparison can never be true). Removed. Surfaced during Phase 2 closeout when the foundation hint string was audited against the actual enum. * feat(foundation/policy/communications): Priority + NOTIFY_SENDER_ROLES + ACK_REQUIRED_BY_TYPE * feat(foundation/policy/communications): CHANNELS catalog (channel topology) * refactor(agents_config): derive CHANNEL_ACCESS from foundation.communications * refactor(seeds): derive DEFAULT_CHANNELS / CHANNEL_MEMBERSHIPS from foundation * refactor(content_actions): derive notify allowlist + priorities from foundation Replaces _NOTIFY_ALLOWED_ROLES + _VALID_NOTIFY_PRIORITIES literals with derivations from foundation.communications.NOTIFY_SENDER_ROLES + Priority. Behavior change: pre-Phase-3 the literal frozenset {cell_pm, main_pm, product_owner, head_marketing} excluded CEO. Foundation includes CEO (per spec 5.5). The contradiction with agents_config.NOTIFICATION_PERMISSIONS (which already granted CEO can_send=True) is now resolved. * refactor(notification_delivery): requires_ack from foundation.ACK_REQUIRED_BY_TYPE * refactor(enforcement,agents_config): delete dead notification policy - enforcement/notification_perms.py deleted (dead at call-graph; only the enforcement/__init__.py re-export kept it reachable, and that re-export is gone too). - agents_config.NOTIFICATION_PERMISSIONS dict deleted; agents_config .can_send_notifications now derives from foundation.policy.communications.NOTIFY_SENDER_ROLES (auditor correctly excluded — silent observer per spec §5.5). - services/permissions.py: _can_role_send_notifications and can_agent_send_notifications now derive from NOTIFY_SENDER_ROLES; _get_notification_scope encodes the scope rule (cell/all/list) locally as a function-of-role and returns list[AgentRole] instead of list[slug]; can_notify list-scope branch updated to match. - enforcement/__init__.py: removed the notification_perms re-export and the NotificationPermissionError, get_notification_scope, validate_notification_permission names from __all__. Closes the spec §3 contradiction: gateway content_actions ._NOTIFY_ALLOWED_ROLES (Task 5) and the legacy agents_config.NOTIFICATION_PERMISSIONS no longer disagree about whether auditor may call notify(). Both now derive from foundation.NOTIFY_SENDER_ROLES. * fix(content_actions): runtime auditor guard in say/dm (defense in depth) Closes the spec §5.5 gap where the auditor's silent role was enforced ONLY by manifest exclusion. The manifest pre-filters the tool surface exposed to the auditor agent, but if anything bypassed it, the auditor could speak. The new runtime guard in ContentActions.say/dm refuses with Envelope.not_authorized when the caller's role is "auditor", regardless of how the call arrived. * fix(a2a): pass Priority tristate end-to-end (was reduced to boolean) Pre-Phase-3 path: request priority: str -> services/a2a.py reduces to urgent: bool -> services/notification.py maps bool back to NotificationPriority This made Priority.HIGH unreachable through the A2A path. After this fix the full tristate (NORMAL/HIGH/URGENT) survives end-to-end: * services/a2a.py:create_a2a_notification parses metadata["priority"] (preferred) or falls back to legacy metadata["urgent"] / config.urgent (URGENT-only). Unknown values fall back to NORMAL. * services/notification.py:send_a2a_notification now takes a2a_context["priority"] (NotificationPriority); a defensive bool/str coerce keeps legacy callers from crashing. * runtime/orchestrator.py:_build_a2a_prompt reads priority off the notification row (the source of truth) instead of a non-existent metadata.urgent and renders three tiers: URGENT bold, HIGH softer, NORMAL no prefix. Cosmetic [URGENT] body/subject prefix stays urgent-only; HIGH gets no prefix but is recorded as HIGH at the NotificationTable.priority column. Tests: * 9 new tests in tests/integration/test_a2a_priority_tristate.py pinning the round-trip for HIGH/NORMAL/URGENT through both layers plus legacy-bool backcompat. * Updated tests/unit/services/test_notification.py::test_send_a2a_notification to the new priority= contract. Closes the spec section 3 contradiction flagged in the audit. * feat(foundation/policy): agent_loop BudgetPolicy + VERB_RETRY_LIMITS * refactor(agent_sdk): import budget thresholds from foundation * refactor(orchestrator): import _PM_RESPAWN_MAX_UNPRODUCTIVE from foundation * fix(post-tool-budget-hook): exit 1 on loop-halt (was exit 0 / non-blocking) Pre-Phase-3 the hook printed [Loop] and exit 0'd — agents could ignore it and keep retrying. The 2026-05-10 smoke run showed i_am_done retried 5+ times within the global 150-tool budget, never hitting a real wall. Now the hook reads the SDK response's loop_action field (sourced from foundation.BudgetPolicy.loop_action; default "halt") and exits 1 to deny the wrapping tool call when the rolling-window loop detector fires AND loop_action is "halt". Operators can soften via env ROBOCO_AGENT_LOOP_ACTION=warn for debugging. Changes: - BudgetStatus pydantic model: add loop_action: Literal["warn", "halt"] (default "halt") so the SDK response carries the policy. - agent_sdk/server.py: read ROBOCO_AGENT_LOOP_ACTION env override on top of foundation default and surface it in _budget_snapshot(). - post-tool-budget-hook.sh: parse .loop_action, exit 1 to stderr when loop+halt; falls back to legacy warn-only print if the field is missing (older SDK / partial deploy). * feat(agent_sdk): per-verb retry circuit breaker via foundation.VERB_RETRY_LIMITS Pre-Phase-3 the gateway had no per-verb retry cap. The 2026-05-10 smoke showed i_am_done retried 5+ times in 2 minutes within the global 150-tool budget — the agent never hit a real wall. Now the SDK tracks (verb, task_id) -> deque[timestamp] over a 60s sliding window. When the count for a verb exceeds foundation.retry_limit_for(verb), the next attempt receives Envelope.circuit_open with a remediate hint pointing to i_am_blocked / i_am_idle as graceful exits. Verbs in foundation.UNLIMITED_RETRY_VERBS (give_me_work, triage, evidence, etc.) bypass the breaker. Only rejection envelopes (tracing_gap, invalid_state, not_authorized, incomplete_input) feed the counter — successful calls do not count. Wire-up: - Envelope.circuit_open classmethod + as_dict pass-through - _SessionState.verb_attempts: defaultdict[(verb, task_id), deque[float]] - Helpers _record_verb_attempt / _verb_attempt_count / _check_verb_circuit - POST /verb/attempted: hook posts after a rejected gateway call; response carries breaker state + (when open) the wire-format Envelope.circuit_open dict the agent should surface to itself - GET /verb/circuit_status: read-only state probe - _state.reset() (also POST /budget/reset) wipes the tracker on spawn * test(foundation): Phase 3 smoke gate + foundation-check extended Phase 3 closes here: - foundation/policy/communications.py owns Priority, NOTIFY_SENDER_ROLES, ACK_REQUIRED_BY_TYPE, ChannelSpec, CHANNELS, parse_priority - foundation/policy/agent_loop.py owns BudgetPolicy, VERB_RETRY_LIMITS, UNLIMITED_RETRY_VERBS, retry_limit_for - 6 channel topology fork sites collapsed to one source (CHANNELS) - Notification sender contradiction closed (CEO included; auditor excluded) - A2A urgency tristate restored (HIGH reachable end-to-end); A2A service now consumes parse_priority instead of inlining branches (also drops create_a2a_notification CC from C/13 to A/<10) - Auditor silent role enforced at runtime in say/dm - enforcement/notification_perms.py deleted (was dead code) - 7 hand-set requires_ack callsites consolidated to ACK_REQUIRED_BY_TYPE - post-tool-budget-hook.sh exits 1 on loop-halt - Per-verb retry circuit breaker live in agent_sdk (60s sliding window) make foundation-check now validates communications + tracing + journaling + identity drift in one command. make quality green. * refactor(lifecycle): copy spec.py to foundation/policy/lifecycle.py + shim Phase 4 Task 1 — relocates the canonical lifecycle spec next to its policy siblings (task_completeness, tracing, journaling, communications, agent_loop). The original roboco/lifecycle/spec.py is now an explicit re-export shim; consumers continue to work unchanged. Subsequent Phase 4 tasks (2-7) migrate the imports in batches, then Task 8 deletes the shim. No behavior change — pure code move. * refactor(services): import lifecycle from foundation (Phase 4 batch) * refactor(agents,enforcement): import lifecycle from foundation (Phase 4 batch) * refactor(tests): import lifecycle from foundation (Phase 4 batch) * refactor(foundation): absorb lifecycle _validate + _generators Phase 4 Tasks 9 + 10. Moves the lifecycle spec's internal validators to roboco/foundation/_validate_lifecycle.py and its RAG/prompt artifact emitter to roboco/foundation/_generators.py. The lifecycle validators live in a sibling module (not merged with foundation/_validate.py) because the lifecycle spec imports from foundation at module load — placing the lifecycle checks alongside the identity checks would create an import cycle between roboco.foundation and roboco.foundation.policy.lifecycle (the latter calls the validators at the bottom of its own definition). The _validate_lifecycle module defers its policy.lifecycle imports to function bodies so it loads cleanly when the spec hasn't finished initialising yet; the per-file PLC0415 exemption in pyproject.toml documents the reason. Test files relocated: - tests/lifecycle/test_spec.py -> tests/foundation/test_lifecycle_spec.py - tests/lifecycle/test_generators.py -> tests/foundation/test_lifecycle_generators.py scripts/build_lifecycle_artifacts.py now imports the generators from roboco.foundation; the on-disk artifacts (docs/rag/lifecycle, panel/lib/lifecycle.json, agents/prompts/_generated/lifecycle-*.md) regenerate byte-identically. After this commit, roboco/lifecycle/ contains only the spec.py and __init__.py re-export shims — Task 8 deletes those. No behavior change. 6638 tests pass; make quality green. * refactor(lifecycle): delete legacy roboco/lifecycle/ package Phase 4 Task 8. All consumers migrated to roboco.foundation.policy.lifecycle in Tasks 2-7; the internal validators + generators moved to foundation in Tasks 9-10. The legacy package contained only re-export shims. Also trims tests/foundation/test_role_reexport.py — the two assertions that checked the lifecycle.spec shim's object-identity are gone with the shim. The two models.base shim assertions (AgentRole / Team) are still meaningful and stay. Inline docstrings / comments in enforcement/task_lifecycle.py, services/gateway/role_config.py, services/gateway/content_actions.py, tests/integration/test_task_service_lifecycle_misc.py and foundation/policy/lifecycle.py that referenced the now-deleted roboco.lifecycle.spec module are updated to point at roboco.foundation.policy.lifecycle. After this commit, roboco.lifecycle is gone. Lifecycle policy lives only at roboco.foundation.policy.lifecycle. Adding new lifecycle rules edits exactly that one file. * refactor(api): consolidate route-guard role-sets via foundation Replace hand-written role-name string frozensets in roboco/api/deps.py (_PM_OR_ABOVE_ROLES, _DEVELOPER_OR_ABOVE_ROLES, _GLOBAL_CELL_ACCESS_ROLES) and roboco/api/routes/v2/_role_dep.py (require_dev/qa/doc/cell_pm/main_pm/ board/auditor) with foundation-derived expressions over PM_ROLES, BOARD_ROLES, DEV_ROLES, and Role enum members. Behavior is preserved: Role is a StrEnum, so the lowercase X-Agent-Role header still compares equal to its matching member. HEAD_MARKETING stays excluded from every -or-above set (marketing spokesperson, not approver); the carve-out is now expressed as (BOARD_ROLES - {Role.HEAD_MARKETING}) instead of an opaque literal. Adds tests/foundation/test_route_guard_consolidation.py (6 tests) pinning both the foundation-derived membership and the import contract. * test(foundation): Phase 4 smoke gate + housekeeping closeout Phase 4 closes the foundation canonicalization effort (Phases 1-4 spanning 2026-05-10 -> 2026-05-11): Phase 1 - identity + task_completeness (skeleton-task bug killed) Phase 2 - tracing + journaling (pre-gateway cadence restored) Phase 3 - communications + agent_loop (channel/notification/A2A/circuit-breaker) Phase 4 - housekeeping (lifecycle moved to foundation; consumers migrated) All cross-cutting policy now lives in roboco/foundation/. Adding a policy edits exactly one file. The legacy roboco.lifecycle package is gone. Smoke gates 1-4 enforce: no skeleton tasks, no inline journal:decision checks, channel topology canonical, A2A tristate preserved, auditor silent at runtime, lifecycle module path canonical. make quality + make foundation-check both green. * fix(mcp/agent_sdk): wire per-verb circuit breaker into response handler Phase 3 Task 14 added the SDK infrastructure (tracker, endpoints, Envelope.circuit_open, retry_limit_for) but nothing was actually recording rejections — the breaker never tripped. This commit wires the gateway-response path so every rejection envelope (tracing_gap / invalid_state / not_authorized / incomplete_input) hits POST /verb/attempted, and if the breaker is open, the envelope is substituted with the circuit_open response before the agent sees it. Best-effort: SDK-unreachable / malformed-response failures fall open (agent sees the original rejection), so the breaker never breaks the gateway path. * fix(notification_delivery): retype CEO approval-flow notifications APPROVAL notify_assignee_of_ceo_rejection and notify_ceo_of_escalation were both typed NotificationType.TASK_ASSIGNMENT, which the Phase 3 foundation table (ACK_REQUIRED_BY_TYPE in roboco/foundation/policy/communications.py) maps to requires_ack=False. Both are approval-flow notifications and should mandate acknowledgment. Retyped both to NotificationType.APPROVAL so the table lookup yields requires_ack=True via ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL]. * test(foundation): move lifecycle parity + smoke-replay tests under tests/foundation/ Phase 4 Task 8 deleted roboco/lifecycle/ but tests/lifecycle/ still held two files importing roboco.foundation.policy.lifecycle. Mirror the layout of test_lifecycle_spec.py and test_lifecycle_generators.py (moved in Phase 4 Tasks 9+10) by relocating them under tests/foundation/ with the test_lifecycle_* prefix, then delete the now-empty tests/lifecycle/ package. tests/lifecycle/test_consumer_parity.py -> tests/foundation/test_lifecycle_consumer_parity.py tests/lifecycle/test_smoke_replay.py -> tests/foundation/test_lifecycle_smoke_replay.py * build(make): consolidate ci-lifecycle-check into foundation-check ci-lifecycle-check was a thin wrapper that regenerated lifecycle artifacts via scripts/build_lifecycle_artifacts.py and gated on git diff. After Phase 4 it sat alongside foundation-check covering the same drift-gate intent. Merge the lifecycle-artifact regen + git-diff step into foundation-check so a single 'make foundation-check' is the canonical drift gate. Keep ci-lifecycle-check as a phony alias forwarding to foundation-check for any external script or CI lane still using the old target name. Drop the redundant ci-lifecycle-check call from 'make quality'. * ++ --------- Co-authored-by: Renn F --- .claude/scheduled_tasks.lock | 1 + Makefile | 47 + .../prompts/_generated/lifecycle-auditor.md | 7 + .../prompts/_generated/lifecycle-cell_pm.md | 16 + agents/prompts/_generated/lifecycle-ceo.md | 5 + .../prompts/_generated/lifecycle-developer.md | 13 + .../_generated/lifecycle-documenter.md | 12 + .../_generated/lifecycle-head_marketing.md | 8 + .../prompts/_generated/lifecycle-main_pm.md | 17 + .../_generated/lifecycle-product_owner.md | 8 + agents/prompts/_generated/lifecycle-qa.md | 13 + agents/prompts/_generated/lifecycle-system.md | 5 + agents/prompts/roles/board.md | 47 +- agents/prompts/roles/cell_pm.md | 62 +- agents/prompts/roles/developer.md | 63 +- agents/prompts/roles/documenter.md | 45 +- agents/prompts/roles/main_pm.md | 62 +- agents/prompts/roles/qa.md | 49 +- alembic/versions/002_persistence_tables.py | 10 +- alembic/versions/009_enum_reconcile.py | 18 +- .../versions/011_drop_quarantined_state.py | 11 +- ...12_align_agentrole_team_with_foundation.py | 39 + docker/scripts/post-tool-budget-hook.sh | 18 +- docs/rag/lifecycle/intent-verbs.md | 213 ++ docs/rag/lifecycle/status-transitions.md | 36 + panel/lib/lifecycle.json | 558 ++++ pyproject.toml | 4 + roboco/agent_sdk/models.py | 70 + roboco/agent_sdk/server.py | 173 +- roboco/agents/factories/_base.py | 32 +- roboco/agents_config.py | 303 +-- roboco/api/deps.py | 20 +- roboco/api/routes/tasks.py | 28 +- roboco/api/routes/v2/_role_dep.py | 23 +- roboco/api/routes/v2/flow_cell_pm.py | 1 + roboco/api/routes/v2/flow_main_pm.py | 1 + roboco/api/schemas/tasks.py | 36 +- roboco/api/schemas/v2/flow.py | 30 +- roboco/enforcement/__init__.py | 8 - roboco/enforcement/journal_perms.py | 193 +- roboco/enforcement/notification_perms.py | 131 - roboco/enforcement/task_lifecycle.py | 461 ++-- roboco/foundation/__init__.py | 50 + roboco/foundation/_generators.py | 141 + roboco/foundation/_validate.py | 92 + roboco/foundation/_validate_lifecycle.py | 295 ++ roboco/foundation/identity.py | 236 ++ roboco/foundation/policy/__init__.py | 1 + roboco/foundation/policy/agent_loop.py | 96 + roboco/foundation/policy/communications.py | 278 ++ roboco/foundation/policy/journaling.py | 67 + roboco/foundation/policy/lifecycle.py | 1336 ++++++++++ roboco/foundation/policy/task_completeness.py | 298 +++ roboco/foundation/policy/tracing.py | 337 +++ roboco/mcp/flow_server.py | 92 + roboco/models/base.py | 48 +- roboco/models/task.py | 43 +- roboco/runtime/orchestrator.py | 85 +- roboco/seeds/initial_data.py | 501 ++-- roboco/services/a2a.py | 15 +- .../services/gateway/choreographer/_impl.py | 2364 +++++++++++------ .../gateway/choreographer/_protocol.py | 14 +- .../gateway/choreographer/_verb_runner.py | 196 ++ roboco/services/gateway/choreographer/doc.py | 375 ++- roboco/services/gateway/choreographer/qa.py | 403 ++- roboco/services/gateway/claim_guards.py | 114 +- roboco/services/gateway/content_actions.py | 74 +- roboco/services/gateway/envelope.py | 115 +- roboco/services/gateway/remediation.py | 15 + roboco/services/gateway/role_config.py | 83 +- roboco/services/gateway/tracing_gate.py | 123 - roboco/services/gateway/verb_gates.py | 119 - roboco/services/journal.py | 43 +- roboco/services/notification.py | 31 +- roboco/services/notification_delivery.py | 43 +- roboco/services/permissions.py | 88 +- roboco/services/task.py | 31 +- scripts/build_lifecycle_artifacts.py | 56 + scripts/verify_postgres_enums.py | 92 + tests/fixtures/2026-05-08-smoke-trace.json | 194 ++ tests/foundation/__init__.py | 0 tests/foundation/test_agent_loop.py | 96 + tests/foundation/test_agents_config_parity.py | 51 + tests/foundation/test_communications.py | 143 + .../test_communications_consumers.py | 196 ++ tests/foundation/test_identity.py | 220 ++ tests/foundation/test_journaling.py | 84 + tests/foundation/test_journaling_consumers.py | 63 + .../test_lifecycle_consumer_parity.py | 1653 ++++++++++++ tests/foundation/test_lifecycle_generators.py | 49 + .../foundation/test_lifecycle_smoke_replay.py | 366 +++ tests/foundation/test_lifecycle_spec.py | 696 +++++ tests/foundation/test_role_reexport.py | 24 + tests/foundation/test_role_set_parity.py | 41 + .../test_route_guard_consolidation.py | 77 + .../test_seed_orchestrator_parity.py | 53 + tests/foundation/test_task_completeness.py | 153 ++ tests/foundation/test_tracing.py | 187 ++ tests/foundation/test_tracing_verb_parity.py | 27 + tests/foundation/test_validate.py | 85 + .../integration/test_a2a_priority_tristate.py | 392 +++ .../test_foundation_phase1_smoke.py | 150 ++ .../test_foundation_phase2_smoke.py | 87 + .../test_foundation_phase3_smoke.py | 105 + .../test_foundation_phase4_smoke.py | 85 + .../test_full_lifecycle_real_db.py | 1 + tests/integration/test_lifecycle_real_db.py | 819 ++++++ .../test_post_tasks_completeness.py | 130 + .../test_task_service_background.py | 5 + tests/integration/test_task_service_basics.py | 6 + .../test_task_service_lifecycle_misc.py | 27 +- tests/integration/test_task_service_misc.py | 5 +- .../test_task_service_no_silent_fallback.py | 123 + .../test_task_service_route_orchestration.py | 6 + .../test_task_service_transitions.py | 16 +- tests/integration/test_tasks_routes.py | 20 +- .../agent_sdk/test_verb_circuit_breaker.py | 416 +++ tests/unit/api/routes/v2/test_flow_cell_pm.py | 5 +- tests/unit/api/routes/v2/test_flow_main_pm.py | 8 +- .../api/test_delegate_request_completeness.py | 68 + tests/unit/api/test_schemas_v2_flow.py | 10 +- .../unit/api/test_task_update_completeness.py | 43 + .../enforcement/test_notification_perms.py | 88 - .../unit/gateway/test_auditor_silent_guard.py | 126 + .../unit/gateway/test_choreographer_board.py | 19 +- .../test_choreographer_claim_guards.py | 105 +- .../test_choreographer_completion_guards.py | 26 +- .../test_choreographer_delegate_guards.py | 8 +- tests/unit/gateway/test_choreographer_dev.py | 273 +- tests/unit/gateway/test_choreographer_doc.py | 79 +- .../test_choreographer_impl_branches.py | 100 +- tests/unit/gateway/test_choreographer_pm.py | 46 +- .../gateway/test_choreographer_pm_extras.py | 185 +- tests/unit/gateway/test_choreographer_qa.py | 137 +- .../test_choreographer_reassignment.py | 156 +- .../test_choreographer_submit_qa_gates.py | 88 +- tests/unit/gateway/test_claim_arg_order.py | 62 +- .../unit/gateway/test_claim_guards_direct.py | 28 +- .../gateway/test_delegate_incomplete_input.py | 224 ++ .../gateway/test_envelope_from_decision.py | 58 + .../gateway/test_envelope_incomplete_input.py | 34 + .../gateway/test_envelope_introspection.py | 5 +- tests/unit/gateway/test_heartbeat_wired.py | 36 +- tests/unit/gateway/test_open_pr.py | 49 +- tests/unit/gateway/test_resume.py | 43 +- tests/unit/gateway/test_role_config.py | 19 +- tests/unit/gateway/test_tracing_gate.py | 169 -- tests/unit/gateway/test_unclaim.py | 20 +- tests/unit/gateway/test_verb_gates.py | 207 -- tests/unit/gateway/test_verb_runner.py | 104 + .../test_flow_server_circuit_breaker.py | 431 +++ .../models/test_task_create_completeness.py | 56 + tests/unit/services/test_notification.py | 10 +- uv.lock | 644 ++--- 154 files changed, 18263 insertions(+), 3644 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 agents/prompts/_generated/lifecycle-auditor.md create mode 100644 agents/prompts/_generated/lifecycle-cell_pm.md create mode 100644 agents/prompts/_generated/lifecycle-ceo.md create mode 100644 agents/prompts/_generated/lifecycle-developer.md create mode 100644 agents/prompts/_generated/lifecycle-documenter.md create mode 100644 agents/prompts/_generated/lifecycle-head_marketing.md create mode 100644 agents/prompts/_generated/lifecycle-main_pm.md create mode 100644 agents/prompts/_generated/lifecycle-product_owner.md create mode 100644 agents/prompts/_generated/lifecycle-qa.md create mode 100644 agents/prompts/_generated/lifecycle-system.md create mode 100644 alembic/versions/012_align_agentrole_team_with_foundation.py create mode 100644 docs/rag/lifecycle/intent-verbs.md create mode 100644 docs/rag/lifecycle/status-transitions.md create mode 100644 panel/lib/lifecycle.json delete mode 100644 roboco/enforcement/notification_perms.py create mode 100644 roboco/foundation/__init__.py create mode 100644 roboco/foundation/_generators.py create mode 100644 roboco/foundation/_validate.py create mode 100644 roboco/foundation/_validate_lifecycle.py create mode 100644 roboco/foundation/identity.py create mode 100644 roboco/foundation/policy/__init__.py create mode 100644 roboco/foundation/policy/agent_loop.py create mode 100644 roboco/foundation/policy/communications.py create mode 100644 roboco/foundation/policy/journaling.py create mode 100644 roboco/foundation/policy/lifecycle.py create mode 100644 roboco/foundation/policy/task_completeness.py create mode 100644 roboco/foundation/policy/tracing.py create mode 100644 roboco/services/gateway/choreographer/_verb_runner.py delete mode 100644 roboco/services/gateway/tracing_gate.py delete mode 100644 roboco/services/gateway/verb_gates.py create mode 100755 scripts/build_lifecycle_artifacts.py create mode 100644 scripts/verify_postgres_enums.py create mode 100644 tests/fixtures/2026-05-08-smoke-trace.json create mode 100644 tests/foundation/__init__.py create mode 100644 tests/foundation/test_agent_loop.py create mode 100644 tests/foundation/test_agents_config_parity.py create mode 100644 tests/foundation/test_communications.py create mode 100644 tests/foundation/test_communications_consumers.py create mode 100644 tests/foundation/test_identity.py create mode 100644 tests/foundation/test_journaling.py create mode 100644 tests/foundation/test_journaling_consumers.py create mode 100644 tests/foundation/test_lifecycle_consumer_parity.py create mode 100644 tests/foundation/test_lifecycle_generators.py create mode 100644 tests/foundation/test_lifecycle_smoke_replay.py create mode 100644 tests/foundation/test_lifecycle_spec.py create mode 100644 tests/foundation/test_role_reexport.py create mode 100644 tests/foundation/test_role_set_parity.py create mode 100644 tests/foundation/test_route_guard_consolidation.py create mode 100644 tests/foundation/test_seed_orchestrator_parity.py create mode 100644 tests/foundation/test_task_completeness.py create mode 100644 tests/foundation/test_tracing.py create mode 100644 tests/foundation/test_tracing_verb_parity.py create mode 100644 tests/foundation/test_validate.py create mode 100644 tests/integration/test_a2a_priority_tristate.py create mode 100644 tests/integration/test_foundation_phase1_smoke.py create mode 100644 tests/integration/test_foundation_phase2_smoke.py create mode 100644 tests/integration/test_foundation_phase3_smoke.py create mode 100644 tests/integration/test_foundation_phase4_smoke.py create mode 100644 tests/integration/test_lifecycle_real_db.py create mode 100644 tests/integration/test_post_tasks_completeness.py create mode 100644 tests/integration/test_task_service_no_silent_fallback.py create mode 100644 tests/unit/agent_sdk/test_verb_circuit_breaker.py create mode 100644 tests/unit/api/test_delegate_request_completeness.py create mode 100644 tests/unit/api/test_task_update_completeness.py delete mode 100644 tests/unit/enforcement/test_notification_perms.py create mode 100644 tests/unit/gateway/test_auditor_silent_guard.py create mode 100644 tests/unit/gateway/test_delegate_incomplete_input.py create mode 100644 tests/unit/gateway/test_envelope_from_decision.py create mode 100644 tests/unit/gateway/test_envelope_incomplete_input.py delete mode 100644 tests/unit/gateway/test_tracing_gate.py delete mode 100644 tests/unit/gateway/test_verb_gates.py create mode 100644 tests/unit/gateway/test_verb_runner.py create mode 100644 tests/unit/mcp_servers/test_flow_server_circuit_breaker.py create mode 100644 tests/unit/models/test_task_create_completeness.py diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..7b3c705c --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"ab5768a5-9435-4c8f-ba8e-e57e202f2e22","pid":23656,"procStart":"Fri May 8 00:44:40 2026","acquiredAt":1778357552778} \ No newline at end of file diff --git a/Makefile b/Makefile index 32f68fd9..cf77f0d0 100644 --- a/Makefile +++ b/Makefile @@ -260,6 +260,8 @@ quality: @uv run alembic upgrade head --sql > /dev/null @echo "==> import-linter (architectural boundaries)" @uv run lint-imports + @echo "==> foundation drift checks (includes lifecycle artifacts)" + @$(MAKE) foundation-check @echo "" @echo "All quality gates passed." @@ -442,3 +444,48 @@ help: show-python-versions: @echo "Supported Python versions: $(PYTHON_VERSIONS)" @echo "Default Python version: $(DEFAULT_PYTHON)" + +# ============================================================================= +# LIFECYCLE ARTIFACTS +# ============================================================================= + +# Regenerate canonical lifecycle artifacts (markdown / JSON / prompt fragments) +# from roboco/lifecycle/spec.py. Output is deterministic; CI gates on +# `make lifecycle && git diff --exit-code`. +.PHONY: lifecycle +lifecycle: + uv run python scripts/build_lifecycle_artifacts.py + +# ============================================================================= +# FOUNDATION DRIFT GATE +# ============================================================================= + +# Canonical drift gate: validates identity tables, runs foundation self-tests, +# regenerates lifecycle artifacts and fails on any uncommitted diff, and +# (when reachable) checks postgres enum parity. Run on every PR — drift +# between foundation tables / lifecycle spec and the committed artifacts +# cannot land on master. +.PHONY: foundation-check +foundation-check: + @echo "==> foundation/identity validators" + uv run python -c "from roboco.foundation import _validate; _validate.run_all(); print(' identity validators: OK')" + @echo "==> foundation/tracing verb parity" + uv run pytest tests/foundation/test_tracing_verb_parity.py --no-cov -q + @echo "==> foundation/journaling consumers" + uv run pytest tests/foundation/test_journaling_consumers.py --no-cov -q + @echo "==> foundation/communications consumers" + uv run pytest tests/foundation/test_communications_consumers.py --no-cov -q + @echo "==> foundation tests (full)" + uv run pytest tests/foundation/ --no-cov -q + @echo "==> lifecycle artifacts up-to-date (renders + git diff)" + @$(MAKE) lifecycle + @git diff --exit-code -- docs/rag/lifecycle panel/lib/lifecycle.json agents/prompts/_generated/lifecycle-*.md \ + || (echo "Lifecycle artifacts are out of date. Run 'make lifecycle' and commit the diff." && exit 1) + @echo "==> postgres enum parity (offline-skip if no DB)" + uv run python scripts/verify_postgres_enums.py || echo " (skipped — postgres unreachable)" + @echo "All foundation drift checks passed." + +# Backwards-compatible alias — prior CI / scripts called `ci-lifecycle-check`. +# `foundation-check` is now the canonical drift gate; this alias just forwards. +.PHONY: ci-lifecycle-check +ci-lifecycle-check: foundation-check diff --git a/agents/prompts/_generated/lifecycle-auditor.md b/agents/prompts/_generated/lifecycle-auditor.md new file mode 100644 index 00000000..6c8958d0 --- /dev/null +++ b/agents/prompts/_generated/lifecycle-auditor.md @@ -0,0 +1,7 @@ +# Verbs available to your role (auditor) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **triage**: List actionable tasks in your scope. diff --git a/agents/prompts/_generated/lifecycle-cell_pm.md b/agents/prompts/_generated/lifecycle-cell_pm.md new file mode 100644 index 00000000..5426df33 --- /dev/null +++ b/agents/prompts/_generated/lifecycle-cell_pm.md @@ -0,0 +1,16 @@ +# Verbs available to your role (cell_pm) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + +- **complete**: Cell PM merges leaf PR + transitions to completed; Main PM merges root PR + escalates to CEO. +- **delegate**: Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/documentation). +- **escalate_up**: Escalate to your role's escalation_target. +- **give_me_work**: Return your most-actionable task or signal idle. +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **i_will_plan**: PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks. +- **resume**: Resume a paused task you own. paused -> in_progress. +- **submit_up**: Cell PM bubbles a finished cell-scope task up to Main PM. +- **triage**: List actionable tasks in your scope. +- **unblock**: PM unblocks a blocked task; restores pre-block state. +- **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved. diff --git a/agents/prompts/_generated/lifecycle-ceo.md b/agents/prompts/_generated/lifecycle-ceo.md new file mode 100644 index 00000000..7720633f --- /dev/null +++ b/agents/prompts/_generated/lifecycle-ceo.md @@ -0,0 +1,5 @@ +# Verbs available to your role (ceo) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + diff --git a/agents/prompts/_generated/lifecycle-developer.md b/agents/prompts/_generated/lifecycle-developer.md new file mode 100644 index 00000000..0a48274a --- /dev/null +++ b/agents/prompts/_generated/lifecycle-developer.md @@ -0,0 +1,13 @@ +# Verbs available to your role (developer) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + +- **give_me_work**: Return your most-actionable task or signal idle. +- **i_am_blocked**: Escalate to PM. Logs a struggle journal entry. +- **i_am_done**: Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit. +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **i_will_work_on**: Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation. +- **open_pr**: Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done. +- **resume**: Resume a paused task you own. paused -> in_progress. +- **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved. diff --git a/agents/prompts/_generated/lifecycle-documenter.md b/agents/prompts/_generated/lifecycle-documenter.md new file mode 100644 index 00000000..23c36188 --- /dev/null +++ b/agents/prompts/_generated/lifecycle-documenter.md @@ -0,0 +1,12 @@ +# Verbs available to your role (documenter) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + +- **claim_doc_task**: Claim awaiting_documentation. Returns evidence inline. +- **give_me_work**: Return your most-actionable task or signal idle. +- **i_am_blocked**: Escalate to PM. Logs a struggle journal entry. +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **i_documented**: Signal docs complete. Transitions to awaiting_pm_review. +- **resume**: Resume a paused task you own. paused -> in_progress. +- **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved. diff --git a/agents/prompts/_generated/lifecycle-head_marketing.md b/agents/prompts/_generated/lifecycle-head_marketing.md new file mode 100644 index 00000000..a317dba5 --- /dev/null +++ b/agents/prompts/_generated/lifecycle-head_marketing.md @@ -0,0 +1,8 @@ +# Verbs available to your role (head_marketing) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + +- **escalate_to_ceo**: Escalate to CEO with reason. Transitions to awaiting_ceo_approval. +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **triage**: List actionable tasks in your scope. diff --git a/agents/prompts/_generated/lifecycle-main_pm.md b/agents/prompts/_generated/lifecycle-main_pm.md new file mode 100644 index 00000000..ad5270fb --- /dev/null +++ b/agents/prompts/_generated/lifecycle-main_pm.md @@ -0,0 +1,17 @@ +# Verbs available to your role (main_pm) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + +- **complete**: Cell PM merges leaf PR + transitions to completed; Main PM merges root PR + escalates to CEO. +- **delegate**: Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/documentation). +- **escalate_to_ceo**: Escalate to CEO with reason. Transitions to awaiting_ceo_approval. +- **escalate_up**: Escalate to your role's escalation_target. +- **give_me_work**: Return your most-actionable task or signal idle. +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **i_will_plan**: PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks. +- **resume**: Resume a paused task you own. paused -> in_progress. +- **triage**: List actionable tasks in your scope. +- **triage_all**: List actionable tasks across all teams (Main PM only). +- **unblock**: PM unblocks a blocked task; restores pre-block state. +- **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved. diff --git a/agents/prompts/_generated/lifecycle-product_owner.md b/agents/prompts/_generated/lifecycle-product_owner.md new file mode 100644 index 00000000..a411937c --- /dev/null +++ b/agents/prompts/_generated/lifecycle-product_owner.md @@ -0,0 +1,8 @@ +# Verbs available to your role (product_owner) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + +- **escalate_to_ceo**: Escalate to CEO with reason. Transitions to awaiting_ceo_approval. +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **triage**: List actionable tasks in your scope. diff --git a/agents/prompts/_generated/lifecycle-qa.md b/agents/prompts/_generated/lifecycle-qa.md new file mode 100644 index 00000000..37e03e6e --- /dev/null +++ b/agents/prompts/_generated/lifecycle-qa.md @@ -0,0 +1,13 @@ +# Verbs available to your role (qa) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + +- **claim_review**: Claim a task in awaiting_qa for review. Returns evidence inline. +- **fail_review**: Fail QA with concrete issues. Transitions to needs_revision. +- **give_me_work**: Return your most-actionable task or signal idle. +- **i_am_blocked**: Escalate to PM. Logs a struggle journal entry. +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **pass_review**: Pass QA. Transitions awaiting_qa -> awaiting_documentation. +- **resume**: Resume a paused task you own. paused -> in_progress. +- **unclaim**: Voluntarily release a claim back to pending. The work-in-progress branch is preserved. diff --git a/agents/prompts/_generated/lifecycle-system.md b/agents/prompts/_generated/lifecycle-system.md new file mode 100644 index 00000000..08cd679b --- /dev/null +++ b/agents/prompts/_generated/lifecycle-system.md @@ -0,0 +1,5 @@ +# Verbs available to your role (system) + +These are the only verbs the gateway will accept from you. Calling any +other verb will be rejected with a Decision telling you the right one. + diff --git a/agents/prompts/roles/board.md b/agents/prompts/roles/board.md index 7cd896f4..7e1dddc6 100644 --- a/agents/prompts/roles/board.md +++ b/agents/prompts/roles/board.md @@ -30,13 +30,52 @@ If you find yourself reaching for `Bash git`, `Edit`, or any execution tool, sto | `notify(target, text, priority?)` | Send a formal ack-required notification to an agent (`be-dev-1`, `ceo`, etc.). `priority` is one of `normal`/`high`/`urgent` (default `normal`). **Auditor cannot use this — silent observer.** | None for PO/HoM; denied for Auditor. | | `i_am_idle()` | Exit cleanly. | None. | +## State → Verb (tasks you observe) + +| Task status | Next call | +|---|---| +| `pending` / `claimed` / `in_progress` (Main PM and below working) | observe only — `evidence(task_id)` then `note(scope='reflect')` if needed; do NOT claim, delegate, or escalate prematurely | +| `awaiting_pm_review` | inspect the aggregate via `evidence` → `note(scope='decision', ...)` → if strategic concern, `escalate_to_ceo(task_id, ...)`; otherwise leave it for Main PM and CEO | +| `awaiting_ceo_approval` | NOT yours — CEO owns this state. Observe only. | +| `blocked` | `note(scope='reflect')` capturing what the blocker reveals at the strategic level; escalate if it indicates a systemic issue | +| `completed` / `cancelled` | strategic post-mortem via `note(scope='reflect')` if there's a lesson worth recording | + +**Auditor**: every row above ends in `note(scope='reflect')` and `i_am_idle()`. You have no `say`/`dm`/`escalate_*` — your only output is the journal, which the CEO reads. + ## Workflow 1. `triage()` -> see the next strategic task or alert. -2. `evidence(task_id)` -> read PR, dev journals, QA notes, PM decisions. -3. `note(scope='decision', task_id=..., text="")`. -4. If it's CEO-worthy: `escalate_to_ceo(task_id, reason="...")`. -5. If it's just an observation: `note(scope='reflect', ...)` and `i_am_idle()`. +2. `evidence(task_id)` -> read PR, dev/QA/doc journals, PM decisions, full lifecycle history. **The journal aggregate is what gives you signal — read it before any strategic call.** +3. `note(scope='decision', task_id=..., text="")` — required before `escalate_to_ceo`. +4. If it's CEO-worthy: `escalate_to_ceo(task_id, reason="...")`. (PO + Head of Marketing only — Auditor cannot escalate; record critical observations as reflect-notes for the CEO to find.) +5. If it's just an observation: `note(scope='reflect', text='...')` and `i_am_idle()`. + +## Journaling cadence + +The Board's journal IS the work product. Most of what you do never produces a verb call — it produces a recorded observation that the CEO and Main PM consume: + +| Scope | When | Example | +|---|---|---| +| `note` | Quick observations during triage | "Backend cell shipped 3 features in the last week; frontend shipped 0 — worth understanding why" | +| `decision` | Before EVERY `escalate_to_ceo` (gateway-required). PO/HoM only — Auditor doesn't escalate. | (PO) "Recommending CEO descope feature X; QA flagged repeated regressions and the dev journal shows scope creep" | +| `struggle` | When you can't tell whether to escalate | (HoM) "Announcement timing for feature Y is contested between Product and Engineering. Going to dm Product before deciding." | +| `learning` | When a strategic pattern emerges | (Auditor) "Cells consistently miss the doc step when QA is rushed — propose a 2-day post-QA buffer in next quarter" | +| `reflect` | The Board's primary output. After every triage. After every observation. The Auditor's ONLY output. | (Auditor) "Reviewed 8 PRs this week. 6/8 had explicit acceptance-criteria walks in the dev reflect note. 2/8 didn't — flagging be-dev-2 for journaling guidance from cell PM." | + +## Mandatory checklist before `escalate_to_ceo` (PO / HoM only) + +1. ✅ The task is in a state where Board escalation is meaningful — typically `awaiting_pm_review`, `blocked`, or a strategic question that emerged from triage. Don't escalate while a cell or Main PM is actively working. +2. ✅ You read the full lifecycle journal — `evidence(task_id)` returns dev `decision`/`reflect`, QA `learning`, PM `decision` chain. Escalating without reading is treating the CEO as a triage layer. +3. ✅ `note(scope='decision', task_id=..., text='')` written (gateway-enforced as `journal:decision`). +4. ✅ `reason` argument to `escalate_to_ceo` is concrete: what decision you want the CEO to make, what options you considered, what the trade-offs are. "FYI" is not a reason. + +## Mandatory checklist before any `note(scope='reflect')` from the Auditor + +The Auditor has no escalation verb — every observation flows through the journal. Quality of the journal entry IS the quality of the audit: + +1. ✅ Reflect notes name SPECIFIC tasks/agents/PRs — never generic ("the team is doing well"). +2. ✅ Patterns reference at least 2 examples ("be-dev-1 task X and be-dev-2 task Y both skipped the struggle note when blocked"). One example is an observation; two is a pattern; three is a finding worth a CEO eye. +3. ✅ Each reflect note ends with either (a) "no action needed", (b) "Main PM should review", or (c) "CEO should review" — give the reader a routing hint, since you cannot route via verbs. ## Anti-patterns diff --git a/agents/prompts/roles/cell_pm.md b/agents/prompts/roles/cell_pm.md index 239f79d2..2a4ab79f 100644 --- a/agents/prompts/roles/cell_pm.md +++ b/agents/prompts/roles/cell_pm.md @@ -35,19 +35,67 @@ You merge what your developers submit (leaf PRs into your cell branch via `compl | `evidence(task_id)` | Inspect a task's PR + commits + diff. | None. | | `i_am_idle()` | Exit cleanly; auto-pauses any `in_progress` tasks you own so you'll be respawned at the right moment. | None. | +## State → Verb (YOUR cell-PM task) + +| Task status | Next call | +|---|---| +| `pending` (assigned to you) | `evidence(task_id)` to read scope → `note(scope='decision', ...)` → `i_will_plan(task_id, plan='...')` | +| `claimed` (your prior claim is intact) | `i_will_plan(task_id, plan='resume: ')` — composes claim+set_plan+start; resumes from `claimed`. **Never `resume` (paused-only), `delegate` (rejected on claimed), `complete`, `escalate_*`, or `unblock` on a claimed task.** | +| `in_progress`, no children yet | `delegate(parent_task_id=task_id, ...)` — usually ONE dev subtask is enough | +| `in_progress`, children exist and active | `i_am_idle()` — closure dispatcher will respawn you when a child needs review or all children terminal | +| `in_progress`, all children terminal | `note(scope='decision', ...)` → `submit_up(task_id, notes='...')` | +| `blocked` | If you can't fix the delegation problem, `escalate_up(task_id, reason='...')` to Main PM | +| `paused` | `resume(task_id)` | +| `awaiting_pm_review` (yours) | `i_am_idle()` — Main PM owns the next move | + +## State → Verb (a SUBTASK in your cell) + +| Subtask status | Next call | +|---|---| +| `pending` / `in_progress` / `claimed` (the dev is working) | leave it alone; orchestrator respawns the dev as needed | +| `blocked` (resolver=agent) | investigate → fix root cause → `unblock(subtask_id)` | +| `blocked` (resolver=human) | `escalate_up(subtask_id, reason='...')` | +| `awaiting_pm_review` (a dev's leaf came back) | `evidence(subtask_id)` to review diff → `note(scope='decision', text='merge rationale')` → `complete(subtask_id, notes='...')` (auto-merges into your branch) | +| `needs_revision` | dev re-claims; you stay out | + ## Workflow -1. `evidence(task_id="")` -> read the description, acceptance criteria, parent context. -2. `note(scope='decision', task_id="", text="")`. -3. `i_will_plan(task_id="", plan="")` -> claims, branches, sets `in_progress`. -4. `delegate(parent_task_id="", assigned_to="", ...)` -> repeat per focused subtask. -5. `i_am_idle()` -> wait. The orchestrator's closure dispatcher will respawn you when (a) a subtask reaches `awaiting_pm_review` for your review, or (b) all your subtasks are terminal and your task is ready to submit up. -6. On respawn for a subtask: `evidence(subtask_id)` -> review diff -> `note(scope='decision', ...)` -> `complete(subtask_id, notes=...)`. The leaf PR auto-merges into your cell branch. -7. On respawn after all subtasks terminal: `evidence(your_task_id)` -> `note(scope='decision', ...)` -> `submit_up(your_task_id, notes=...)`. Main PM takes over. +1. `evidence(task_id="")` -> read the description, acceptance criteria, parent context, **the list of children that already exist**, and Main PM's journal entries to understand intent. +2. **If your task already has subtasks (any non-terminal child), do NOT delegate again.** You are being respawned to coordinate, not to re-decompose. Skip to step 6 (`i_am_idle` until a child needs you) or step 7 (review a child in `awaiting_pm_review`). +3. `note(scope='decision', task_id="", text="")` — the decision note explains your delegation rationale to QA / Main PM / future agents reading the journal. +4. `i_will_plan(task_id="", plan="")` -> claims, branches, sets `in_progress`. **If your task is already in `claimed` state on respawn, call `i_will_plan` again — it resumes from claimed back into `in_progress`.** +5. `delegate(parent_task_id="", assigned_to="", ...)`. **Default to ONE dev subtask per logical unit of work.** A single subtask flows through the lifecycle as: dev → QA → documenter → you (merge). The lifecycle engages those roles automatically; you do NOT split into per-role subtasks (no "branch naming subtask", "PR workflow subtask", etc.). Create additional dev subtasks only when the work is genuinely separable (independent files, no shared state). +6. `i_am_idle()` -> wait. The orchestrator's closure dispatcher will respawn you when (a) a subtask reaches `awaiting_pm_review` for your review, or (b) all your subtasks are terminal and your task is ready to submit up. +7. On respawn for a subtask: `evidence(subtask_id)` -> review diff + dev's `reflect` note + QA's `learning` note + doc's commits -> `note(scope='decision', text='merge rationale')` -> `complete(subtask_id, notes=...)`. The leaf PR auto-merges into your cell branch. +8. On respawn after all subtasks terminal: `evidence(your_task_id)` -> read every child's journal aggregate -> `note(scope='reflect', text='')` -> `note(scope='decision', text='submit-up rationale')` -> `submit_up(your_task_id, notes=...)`. Main PM takes over. + +## Journaling cadence + +The PM journal is what makes the cell legible to Main PM and CEO. Skipping entries means upstream reviewers can't see your reasoning: + +| Scope | When | Example | +|---|---|---| +| `note` | Quick observations | "be-dev-1 has a paused task from yesterday; will reuse rather than create new" | +| `decision` | Before EVERY `i_will_plan` / `delegate` / `complete` (subtask) / `submit_up` / `escalate_*` (gateway-required for several of these) | "Delegating commit-format work to be-dev-1 over be-dev-2 because dev-1 already touched this area in task XYZ" | +| `struggle` | When delegation is unclear or a dev is stuck and you can't help | "be-dev-2 keeps failing the same migration test; not sure if it's their misunderstanding or my unclear acceptance criterion. Going to add detail then dm them." | +| `learning` | When a cell pattern emerges worth surfacing | "We keep splitting 'add endpoint + add tests' into 2 subtasks. Should be 1 — TDD inside a single subtask is faster." | +| `reflect` | Before `submit_up` — aggregate review of the whole slice | "Cell delivered 1 dev subtask covering all 4 acceptance criteria. QA passed clean, docs updated README §Auth. PR ready for Main PM merge." | + +## Mandatory checklist before `submit_up` + +1. ✅ Every subtask under your task is in a terminal state (`completed` or `cancelled`) — gateway-enforced. +2. ✅ You inspected each child's PR (already merged into your branch via `complete`) — call `evidence(your_task_id)` for the aggregate diff. +3. ✅ Each acceptance criterion on YOUR cell-PM task is met by something in the aggregate (commit / merged PR / doc). +4. ✅ Tests/lint on the aggregate are green — your branch is the integration point for the cell, so run `make quality` (or equivalent) before submitting up. +5. ✅ `note(scope='reflect', task_id=...)` written — aggregate review. +6. ✅ `note(scope='decision', task_id=...)` written — submit-up rationale (gateway-required). +7. ✅ `notes` argument to `submit_up` >= 20 chars (gateway-enforced). ## Anti-patterns - ❌ Creating > 12 subtasks per parent (the hard cap). Soft-warn fires at 8 — at that point consolidate; if you genuinely need more than 12, the work is too big for a single cell-PM scope — split your parent into two parents. The gateway returns an `invalid_state` envelope whose `message` reads "parent already has N subtasks; cap is 12" once you cross the hard cap. +- ❌ Re-decomposing on respawn. If you're respawned and `evidence(your-task-id)` shows your task already has children (pending, in_progress, blocked, etc.), do NOT create new subtasks — that creates duplicates. Either `triage()` to inspect their state then `i_am_idle` (waiting on a dev), or pick up an `awaiting_pm_review` child and `complete` it. New subtasks are only ever created on the first respawn after `i_will_plan`. +- ❌ Creating multiple dev subtasks for one logical unit of work. The lifecycle pulls QA + Documenter + PM-merge through automatically for any single dev subtask — you do not need separate subtasks for "test the X", "test the Y", "validate Z" if those are facets of the same workflow. Default to one dev subtask per logical unit. - ❌ Calling `delegate` before `i_will_plan`. The gateway returns an `invalid_state` envelope whose `message` reads "parent task is in pending; must be in_progress to accept subtasks" — `remediate` tells you to call `i_will_plan` first. - ❌ Running `Bash git ...` or `Bash curl http://orchestrator/...`. You have no commit verb; the gateway covers everything you need (`complete` merges, `submit_up` opens the cell PR). Raw git/curl is denied at the bash-guard layer. - ❌ Trying to claim a code task yourself. The gateway returns a `not_authorized` envelope whose `message` reads "Cell PM cannot claim code tasks. PMs coordinate, never execute code." Decompose and `delegate` instead. diff --git a/agents/prompts/roles/developer.md b/agents/prompts/roles/developer.md index 3d4ab7a2..358f7cf2 100644 --- a/agents/prompts/roles/developer.md +++ b/agents/prompts/roles/developer.md @@ -30,17 +30,64 @@ You write code; you do not coordinate. If you find yourself thinking "let me als | `evidence(task_id)` | Fetches PR diff, commits, files changed, dev summary. | None. | | `i_am_idle()` | Done for now; soft-blocks if you have unread A2A or @mentions. | No active task locks. | +## State → Verb + +When you respawn, your task is in some lifecycle status. The next call follows from that status — never guess; consult this table. + +| Your task status | Next call | +|---|---| +| `pending` (assigned to you) | `evidence(task_id)` to re-read description + acceptance criteria → `note(scope='decision', text='approach: ')` → `i_will_work_on(task_id, plan='...')` | +| `claimed` (your prior claim is intact, work not yet started) | `i_will_work_on(task_id, plan='resume: ')` — composes claim+set_plan+start; resumes from `claimed` into `in_progress` | +| `in_progress`, no commits yet | `evidence(task_id)` to confirm scope → start editing → `commit(message)` | +| `in_progress`, edits made, not yet tested | run tests via `Bash` → on green, `commit(message)` | +| `in_progress`, satisfied with the work | `note(scope='reflect', text='...')` → `open_pr(task_id)` → `i_am_done(task_id, notes='...')` | +| `needs_revision` (QA failed, back to you) | `evidence(task_id)` to read `qa_notes` → `note(scope='decision', text='fix plan: ')` → `i_will_work_on(task_id, plan='...')` → fix → re-submit | +| `blocked` | If you can't unstick yourself, `i_am_blocked(reason='...')` and let your PM resolve it. Do NOT try other verbs on `blocked`. | +| `paused` | `resume(task_id)` (transitions paused → in_progress; only valid when you own a paused task) | +| `awaiting_qa` / `awaiting_documentation` / `awaiting_pm_review` / `completed` | `i_am_idle()` — work has moved past you | + ## Workflow 1. `give_me_work()` -> task in `pending` or `needs_revision`. -2. `evidence(task_id)` -> read description, acceptance criteria, prior PR/QA notes if any. -3. `i_will_work_on(task_id, plan="")` -> claims, creates branch, sets `in_progress`. -4. Edit / Write your changes inside the workspace. Run tests via `Bash` if needed. -5. `commit(message)` after each meaningful change. Repeat 4-5 until the criteria are met. -6. `note(scope='reflect', text="")` before submitting. -7. `open_pr(task_id="")` -> pushes your branch and opens the PR up to your cell PM's branch. The response includes the PR number. -8. `i_am_done(task_id="", notes="")` -> submit for QA against the PR you just opened. Auto-runs the in_progress→verifying→awaiting_qa transitions. Read the envelope: if it returns an error, the `remediate` field tells you which preconditions are missing. -9. After `i_am_done` succeeds you are finished with this task. `i_am_idle()`. Documenter writes docs; PM merges. You will only be respawned on `needs_revision`. +2. `evidence(task_id)` -> read description, acceptance criteria, prior PR/QA notes if any. **You must re-read every acceptance criterion every time you respawn — they are the contract.** +3. `note(scope='decision', text='')` -> records your reasoning before claiming. +4. `i_will_work_on(task_id, plan="")` -> claims, creates branch, sets `in_progress`. +5. Edit / Write your changes inside the workspace. Run tests via `Bash` after each meaningful change. +6. `commit(message)` after each meaningful change. The commit auto-records a progress entry. Repeat 5-6 until the criteria are met. +7. If you get stuck (test won't pass, design unclear, deps missing): `note(scope='struggle', text='')` BEFORE moving to `i_am_blocked`. The struggle note gives your PM signal even if you ultimately self-unstick. +8. When a struggle resolves: `note(scope='learning', text='')` so the next agent benefits. +9. `note(scope='reflect', text="")` before submitting. **This reflect note is the artifact behind every acceptance criterion** — it must walk through them. +10. `open_pr(task_id="")` -> pushes your branch and opens the PR up to your cell PM's branch. The response includes the PR number. +11. `i_am_done(task_id="", notes="")` -> submit for QA against the PR you just opened. Auto-runs the in_progress→verifying→awaiting_qa transitions. Read the envelope: if it returns an error, the `remediate` field tells you which preconditions are missing. +12. After `i_am_done` succeeds you are finished with this task. `i_am_idle()`. Documenter writes docs; PM merges. You will only be respawned on `needs_revision`. + +## Journaling cadence + +You have five journal scopes. Use them all — sparse journaling produces opaque work that QA and PM cannot understand later: + +| Scope | When | Example | +|---|---|---| +| `decision` | Before every `i_will_work_on` (or every meaningful approach change) | "Going with adapter pattern over inheritance because the third-party API may change" | +| `note` (default) | Quick observations while working that don't fit other scopes | "Tests in `tests/integration/test_x.py` already cover the happy path; only need edge-case coverage" | +| `struggle` | When stuck for >5 minutes, BEFORE `i_am_blocked` | "Can't get the migration to roll back; tried X, Y, Z. Going to ask PM." | +| `learning` | When a struggle resolves, OR when you discover something the team should know | "asyncpg connection pool needs `max_size` set explicitly; default is too low for our load" | +| `reflect` | Once before `i_am_done` — must walk through every acceptance criterion | "Criterion 1 (X) is met by commit abc, file foo.py:45-60. Criterion 2 (Y)..." | + +The gateway requires `reflect` before `i_am_done`; it will accept your reflect note as the addressing artifact for every acceptance criterion that doesn't have its own explicit citation. + +## Mandatory checklist before `i_am_done` + +The gateway enforces some of these; the rest are convention but failing one of them produces a bad PR. Walk this list every time: + +1. ✅ At least one `commit()` on this branch (gateway-enforced). +2. ✅ Every acceptance criterion is met by actual code or test, not just intention. Re-read them via `evidence(task_id)`. +3. ✅ Tests/lint/typecheck pass locally — run them via `Bash`. If your project has `make quality` (or equivalent), run it; QA will run it too and fail you if it's red. +4. ✅ `git diff` (call `evidence(task_id)` to inspect) shows nothing stray — no `print()` debugging, no commented-out code, no unrelated edits. +5. ✅ `note(scope='reflect', task_id=...)` walks through every criterion (gateway-enforced as `journal:reflect`). +6. ✅ `open_pr(task_id)` has been called and the response returned a PR number (gateway-enforced via `pr_number` set). +7. ✅ `notes` argument to `i_am_done` is your self-verification summary — what you tested, edge cases considered, anything QA should look at first. + +If any item fails, do not retry `i_am_done`; fix the missing piece first. ## Anti-patterns diff --git a/agents/prompts/roles/documenter.md b/agents/prompts/roles/documenter.md index 3f116db3..8c007352 100644 --- a/agents/prompts/roles/documenter.md +++ b/agents/prompts/roles/documenter.md @@ -28,15 +28,48 @@ You do NOT re-implement the developer's work. You do NOT review or critique the | `evidence(task_id)` | Re-fetches PR diff and commits if needed. | None. | | `i_am_idle()` | Done for now. | No active doc claim. | +## State → Verb + +| Task status | Next call | +|---|---| +| `awaiting_documentation` (your team) | `claim_doc_task(task_id)` — claims and returns inline PR data | +| `claimed` by you, no doc commits yet | `evidence(task_id)` to confirm scope → start writing → `commit(...)` | +| `claimed` by you, doc commits made, not submitted | `note(scope='reflect', ...)` → `i_documented(task_id, notes='...', files=[...])` | +| `awaiting_documentation` but you are the original developer | `unclaim()` — convention forbids documenting your own work | +| `paused` | `resume(task_id)` | +| anything else (`pending`/`in_progress`/`awaiting_qa`/`awaiting_pm_review`/`completed`) | not yours — `i_am_idle()` | + ## Workflow 1. `give_me_work()` -> task in `awaiting_documentation`. -2. `claim_doc_task(task_id)` -> read the response: PR diff, files changed, dev summary, dev's journal. -3. Identify what needs documenting: new endpoints, new commands, new modules, behavior changes, migration notes. -4. `Edit`/`Write` the doc files inside your workspace (e.g. README, `docs/`, inline doc comments). -5. `commit("docs(): ")` — repeat per logical doc commit. -6. `note(scope='reflect', text="")`. -7. `i_documented(task_id, notes="<>=20 chars: what+where>", files=["", ...])`. The gateway pushes and checks parallel-completion (PR exists already from the dev). When both `docs_complete` and `pr_created` are true, the task auto-advances to `awaiting_pm_review`. +2. `claim_doc_task(task_id)` -> read the response in full: PR diff, files changed, dev summary, **and the dev's journal entries (`decision`, `reflect`, `struggle`, `learning`)**. Documentation written without reading the journal will drift from intent. +3. **Read the dev's `reflect` note** — it walks through what changed and why. That's the source material for your docs. +4. `note(scope='decision', text='')` — pin your scope before writing. +5. Identify what needs documenting: new endpoints, new commands, new modules, behavior changes, migration notes, breaking changes that callers must know about. +6. `Edit`/`Write` the doc files inside your workspace (e.g. README, `docs/`, inline doc comments). +7. `commit("docs(): ")` — repeat per logical doc commit. Each commit auto-records a progress entry. +8. `note(scope='reflect', text="")` — required before submission. +9. `i_documented(task_id, notes="<>=20 chars: what+where>", files=["", ...])`. The gateway pushes and checks parallel-completion (PR exists already from the dev). When both `docs_complete` and `pr_created` are true, the task auto-advances to `awaiting_pm_review`. + +## Journaling cadence + +| Scope | When | Example | +|---|---|---| +| `note` | Quick observations while writing | "API change touches the `/orders` endpoint — need to update OpenAPI spec too, not just README" | +| `decision` | Before writing — pin scope and audience | "Doc audience: external integrators. Will write a migration note + updated curl examples; skip internal architecture (separate ADR exists)" | +| `struggle` | When the diff is unclear | "Can't tell from the diff whether the new flag is opt-in or opt-out. DMing dev." | +| `learning` | When you discover patterns to reuse | "Migration notes belong under `docs/migrations/{date}-.md`, not `docs/changelog/` — checked existing structure" | +| `reflect` | Required before `i_documented`. Walk through the diff topic-by-topic. | "Documented: (1) new flag in README §Auth, (2) curl example added, (3) migration note. Did NOT document: internal logger refactor (out of scope)" | + +## Mandatory checklist before `i_documented` + +1. ✅ You are NOT the original developer (convention; gateway is best-effort). +2. ✅ You read the full PR diff AND the dev's journal entries — at minimum the `reflect`. +3. ✅ Doc files are written and `commit()`'d on the task branch (gateway requires `files=[...]` non-empty). +4. ✅ Every behavior change visible in the diff has either a doc update or an explicit "intentionally not documented because X" entry in your reflect note. +5. ✅ `note(scope='reflect', task_id=...)` walks through what was documented vs what was deliberately skipped. +6. ✅ `notes` argument >= 20 chars summarizing what+where (gateway-enforced). +7. ✅ `files=[...]` lists the actual doc-file paths you committed (gateway-enforced non-empty). ## Anti-patterns diff --git a/agents/prompts/roles/main_pm.md b/agents/prompts/roles/main_pm.md index cd73f88d..28b4960c 100644 --- a/agents/prompts/roles/main_pm.md +++ b/agents/prompts/roles/main_pm.md @@ -35,15 +35,61 @@ You merge what your Cell PMs submit (cell PRs into your root branch via `complet | `evidence(task_id)` | Inspect a task's PR + commits + diff. | None. | | `i_am_idle()` | Exit cleanly; auto-pauses any `in_progress` tasks you own so you'll be respawned at the right moment. | None. | +## State → Verb (YOUR root task) + +| Task status | Next call | +|---|---| +| `pending` (assigned to you) | `evidence(task_id)` to read scope → `note(scope='decision', ...)` → `i_will_plan(task_id, plan='...')` | +| `claimed` (your prior claim is intact) | `i_will_plan(task_id, plan='resume: ')` — composes claim+set_plan+start. **The ONLY verb that works on `claimed`. `delegate`/`complete`/`escalate_to_ceo`/`escalate_up`/`resume`/`unblock` all reject with `invalid_state` on a claimed task — do not cycle through them.** | +| `in_progress`, no cell subtasks yet | `delegate(parent_task_id=task_id, assigned_to='be-pm'|'fe-pm'|'ux-pm', ...)` — one per cell needed | +| `in_progress`, cell subtasks active | `i_am_idle()` — closure dispatcher will respawn you when a cell-PM task is ready for your review | +| `in_progress`, all cell subtasks terminal | `note(scope='reflect', ...)` → `note(scope='decision', ...)` → `complete(root_id, notes='...')` (opens master PR + transitions to `awaiting_ceo_approval`) | +| `blocked` | If you can fix the delegation issue, do so + `unblock(task_id)`. Otherwise `escalate_to_ceo(task_id, reason='...')`. | +| `paused` | `resume(task_id)` | +| `awaiting_pm_review` (yours, after `complete` opened the master PR) | `escalate_to_ceo(task_id, reason='...')` | +| `awaiting_ceo_approval` | `i_am_idle()` — CEO owns the next move | + +## State → Verb (a CELL-PM SUBTASK under your root) + +| Subtask status | Next call | +|---|---| +| `pending` / `in_progress` / `claimed` (the cell PM is working) | leave it; orchestrator respawns them as needed | +| `blocked` | investigate → fix delegation issue → `unblock(subtask_id)` | +| `awaiting_pm_review` (a cell PM submitted up) | `evidence(subtask_id)` → `note(scope='decision', text='merge rationale')` → `complete(subtask_id, notes='...')` (auto-merges cell PR into your root branch) | +| `needs_revision` | cell PM re-claims; you stay out | + ## Workflow -1. `evidence(task_id="")` -> read the description, scope, acceptance criteria. -2. `note(scope='decision', task_id="", text="")`. -3. `i_will_plan(task_id="", plan="")` -> claims, branches, sets `in_progress`. -4. `delegate(parent_task_id="", assigned_to="be-pm"|"fe-pm"|"ux-pm", team="backend"|"frontend"|"ux_ui", ...)` -> repeat per cell needing work. One subtask per cell. -5. `i_am_idle()` -> wait. The closure dispatcher respawns you when (a) a cell-PM task reaches `awaiting_pm_review` for your review, or (b) all cell-PM subtasks are terminal and the root is ready to escalate. -6. On respawn for a cell-PM task: `evidence(cell_pm_task_id)` -> review diff -> `note(scope='decision', ...)` -> `complete(cell_pm_task_id, notes=...)`. The cell PR auto-merges into your root branch. -7. On respawn after all cell-PM subtasks terminal: `evidence(root_id)` -> `note(scope='decision', ...)` -> `complete(root_id, notes=...)`. The gateway opens the master PR and transitions root to `awaiting_ceo_approval`. CEO takes it from there. +1. `evidence(task_id="")` -> read the description, scope, acceptance criteria, **the list of cell-PM subtasks that already exist**, and the Board's journal entries (Product Owner / Head of Marketing) to understand strategic intent. +2. **If your root already has children (any non-terminal cell-PM subtask), skip the planning steps — you are being respawned to merge, not to re-decompose.** Go directly to step 7 (review a child in `awaiting_pm_review`) or step 8 (complete root once all children terminal). +3. `note(scope='decision', task_id="", text="")` — visible to CEO and Board. +4. `i_will_plan(task_id="", plan="")` -> claims, branches, sets `in_progress`. **If your root is already in `claimed` on respawn, call `i_will_plan` again — it resumes from claimed.** +5. `delegate(parent_task_id="", assigned_to="be-pm"|"fe-pm"|"ux-pm", team="backend"|"frontend"|"ux_ui", ...)` -> repeat per cell needing work. **One subtask per cell, period.** Each Cell PM further decomposes within their team — that is their job, not yours. Most roots only touch one cell. +6. `i_am_idle()` -> wait. The closure dispatcher respawns you when (a) a cell-PM task reaches `awaiting_pm_review` for your review, or (b) all cell-PM subtasks are terminal and the root is ready to escalate. +7. On respawn for a cell-PM task: `evidence(cell_pm_task_id)` -> review diff + cell PM's `reflect` note + each underlying dev/QA/doc journal aggregate -> `note(scope='decision', text='merge rationale')` -> `complete(cell_pm_task_id, notes=...)`. The cell PR auto-merges into your root branch. +8. On respawn after all cell-PM subtasks terminal: `evidence(root_id)` -> read every cell's journal aggregate -> `note(scope='reflect', text='')` -> `note(scope='decision', text='complete-rationale')` -> `complete(root_id, notes=...)`. The gateway opens the master PR and transitions root to `awaiting_ceo_approval`. CEO takes it from there. + +## Journaling cadence + +You are the integration layer between Cells and CEO. Your journal is what tells the CEO why the work is shaped the way it is: + +| Scope | When | Example | +|---|---|---| +| `note` | Quick observations | "be-pm has be-dev-1 + be-dev-2; both available for backend slice" | +| `decision` | Before EVERY `i_will_plan` / `delegate` / `complete` / `escalate_*` (gateway-required for several) | "Routing this to backend cell only; frontend untouched because the change is purely API-level" | +| `struggle` | When cell escalations conflict or scope is contested | "be-pm escalated saying scope is too big; fe-pm hasn't replied. Need to decide whether to descope or split into two roots." | +| `learning` | When a cross-cell pattern emerges | "When backend exposes a new endpoint, frontend cell needs to be in the loop from day one — not after backend ships" | +| `reflect` | Before `complete(root_id)` — cross-cell aggregate review | "Backend delivered the API change in 1 cell-PM task. No frontend or UX impact. Master PR is straightforward; CEO can approve on review." | + +## Mandatory checklist before `complete(root_id)` + +1. ✅ Every cell-PM subtask under your root is in a terminal state (`completed` or `cancelled`) — gateway-enforced. +2. ✅ You inspected each cell's aggregate (already merged into your root branch via `complete(subtask)`) — call `evidence(root_id)` for the cross-cell diff. +3. ✅ Each acceptance criterion on your root is met by something in the cross-cell aggregate. +4. ✅ Cross-cell integration tests / smoke tests pass — your root branch is what the CEO will see. +5. ✅ `note(scope='reflect', task_id=root_id)` written — cross-cell aggregate review. +6. ✅ `note(scope='decision', task_id=root_id)` written — complete-rationale (gateway-required). +7. ✅ `notes` argument to `complete` >= 20 chars (gateway-enforced). ## Anti-patterns @@ -56,6 +102,8 @@ You merge what your Cell PMs submit (cell PRs into your root branch via `complet - ❌ Calling `complete` on the root before all cell-PM subtasks are terminal. The gateway returns a `tracing_gap` envelope with `missing` containing `subtasks not all terminal`. - ❌ Trying to merge to master yourself. Only the CEO does that. Your `complete` on the root opens the master PR and stops at `awaiting_ceo_approval`. - ❌ Calling `i_will_work_on` (that's a developer verb). Yours is `i_will_plan`. +- ❌ On respawn into `claimed`, trying any verb other than `i_will_plan`. The lifecycle requires `claimed → in_progress` before any state-changing operation; the only verb that does that transition for a PM is `i_will_plan`. `delegate`, `complete`, `escalate_*`, `resume`, `unblock` all reject with `invalid_state` on `claimed`. If you cycle through them looking for one that "feels right", you will burn your tool budget without progressing — call `i_will_plan(task_id, plan='resume')` and continue. +- ❌ Re-decomposing on respawn. If `evidence(root_id)` shows children already exist, do NOT delegate again — that creates duplicates. Either review an `awaiting_pm_review` child or `i_am_idle` until one is ready. ## When the gateway returns an error diff --git a/agents/prompts/roles/qa.md b/agents/prompts/roles/qa.md index 2eb0e733..7821cfae 100644 --- a/agents/prompts/roles/qa.md +++ b/agents/prompts/roles/qa.md @@ -27,16 +27,53 @@ A pass without evidence is a betrayal of your role: the entire downstream chain | `evidence(task_id)` | Re-fetches full PR diff and commits if you need more detail. | None. | | `i_am_idle()` | Done for now. | No active QA claim. | +## State → Verb + +| Task status | Next call | +|---|---| +| `awaiting_qa` (your team) | `claim_review(task_id)` — claims and returns inline PR data | +| `claimed` by you, review not started | re-read inline data → `evidence(task_id)` for full diff if needed → start reviewing | +| `claimed` by you, review in progress | continue reading diff + dev journal → `note(scope='learning', ...)` → `pass` or `fail` | +| `awaiting_qa` but you are the original developer | `unclaim()` and let another QA pick it up — self-review is forbidden | +| `paused` | `resume(task_id)` | +| anything else (`pending`/`in_progress`/`awaiting_documentation`/etc.) | not yours to act on — `i_am_idle()` | + ## Workflow 1. `give_me_work()` -> task in `awaiting_qa`. -2. `claim_review(task_id)` -> read the response: `pr_url`, `commits`, `files_changed`, `dev_summary`, `acceptance_criteria_status`. +2. `claim_review(task_id)` -> read the response in full: `pr_url`, `commits`, `files_changed`, `dev_summary`, `acceptance_criteria_status`, **and the dev's journal entries (`decision`, `reflect`, `struggle`, `learning`)**. The journal tells you why; the diff tells you what. 3. If you need to re-inspect anything, call `evidence(task_id)`. **Do not** grep the workspace or run `Bash git diff` — the diff is in the response. -4. Read the dev's journal entries for this task (returned in evidence) to understand intent. -5. For each acceptance criterion: confirm there is a referencing artifact (commit, progress entry, or file change) AND that the change actually meets it. -6. Run tests/lint via `Bash` if your role permits; otherwise rely on the diff. -7. `note(scope='learning', text="")`. -8. Pass: `pass(task_id, notes="<>=80 chars: what you reviewed, what you confirmed, any caveats>")`. Fail: `fail(task_id, issues=["", "", ...])` — each issue is a single string. Reference criterion id + file + line + expected vs actual inside the string itself. +4. **Read the dev's `reflect` note** — it walks through every acceptance criterion and explains how each is met. Cross-check those claims against the actual diff. +5. For each acceptance criterion individually: confirm there is a referencing artifact (commit, progress entry, or file change) AND that the change actually meets it. Don't batch-approve criteria; check them one at a time. +6. Run tests/lint via `Bash` (e.g. `make quality` or `pytest`) — even if the dev says they passed, you re-run. +7. `note(scope='struggle', text='...')` if you can't decide — flag the ambiguity rather than guess. Then `dm(recipient=, text='')` to ask before failing. +8. `note(scope='learning', text="")` — required before pass/fail. +9. Pass: `pass(task_id, notes="<>=80 chars: what you reviewed, which acceptance criteria were verified by which artifacts, edge cases tested, any caveats>")`. Fail: `fail(task_id, issues=["", "", ...])` — each issue is a single string. Reference criterion id + file + line + expected vs actual inside the string itself. + +## Journaling cadence + +You have five journal scopes. QA's job is fundamentally about evidence — sparse journaling here means a downstream PM can't tell whether you actually inspected the diff or just clicked pass: + +| Scope | When | Example | +|---|---|---| +| `note` | Quick observations while reviewing | "Diff touches 3 files; only `service.py` is load-bearing — others are tests/types" | +| `decision` | Before deciding to pass or fail | "Going to fail this on criterion 2: the rate-limit logic isn't covered by any test" | +| `struggle` | When something is ambiguous and you need to ask | "Criterion says 'graceful degradation' but spec doesn't define what 'graceful' means here. DMing dev." | +| `learning` | Required before pass/fail. Capture what this review taught you. | "asyncio cancellation in this codebase needs `await asyncio.shield(...)` — would have caught this in 5 min if I'd known" | +| `reflect` | Optional — for QA-process retrospection | "Took 40 min to review a 200-line PR; bottleneck was reading the dev journal first. Net positive." | + +The gateway requires `learning` before `pass`/`fail`. Your `notes` argument carries the public verdict; the journal carries the reasoning. + +## Mandatory checklist before `pass` / `fail` + +1. ✅ You are NOT the original developer (gateway-enforced for `claim_review`; the convention also forbids self-pass even if the gate slips). +2. ✅ You read every commit in the PR and the full diff (via `claim_review` response or `evidence`). +3. ✅ You read the dev's journal entries — at minimum the `reflect` note. **Reading the diff alone is insufficient.** +4. ✅ For each acceptance criterion, you can name the specific artifact (commit / file / line) that satisfies it. If you cannot, the criterion is not met → fail. +5. ✅ You ran tests/lint locally (or have explicit, recorded evidence the dev did). A pass with red tests is a betrayal. +6. ✅ `note(scope='learning', task_id=...)` written. +7. ✅ For `pass`: `notes` >= 80 chars, names the criteria you verified and the artifact behind each. +8. ✅ For `fail`: each entry in `issues` is concrete and actionable — criterion + file + line + expected/actual. "Doesn't work" is not an issue. ## Anti-patterns diff --git a/alembic/versions/002_persistence_tables.py b/alembic/versions/002_persistence_tables.py index 11e4aca0..1b385094 100644 --- a/alembic/versions/002_persistence_tables.py +++ b/alembic/versions/002_persistence_tables.py @@ -16,7 +16,7 @@ Adds: """ import sqlalchemy as sa -from alembic import op +from alembic import context, op from sqlalchemy import inspect from sqlalchemy.dialects import postgresql @@ -35,7 +35,15 @@ def _table_exists(name: str) -> bool: audit_log from the ORM metadata). Without this guard, op.create_table raises DuplicateTableError and the whole migration rolls back — so the ALTER TYPE ADD VALUE 'APPROVAL' above it never takes effect either. + + In offline (--sql) mode there is no live DB to introspect — the + bind is a MockConnection that has no inspection system. Treat + "table does not exist" as the offline default so the SQL stub + emits the create_table statements unconditionally; idempotency is + only relevant when running against a live DB. """ + if context.is_offline_mode(): + return False bind = op.get_bind() return inspect(bind).has_table(name) diff --git a/alembic/versions/009_enum_reconcile.py b/alembic/versions/009_enum_reconcile.py index 3f40e240..2f5bdfd3 100644 --- a/alembic/versions/009_enum_reconcile.py +++ b/alembic/versions/009_enum_reconcile.py @@ -26,7 +26,7 @@ Create Date: 2026-05-02 from __future__ import annotations -from alembic import op +from alembic import context, op from sqlalchemy import text revision = "009_enum_reconcile" @@ -46,7 +46,21 @@ _DESIRED_ADDITIONS: dict[str, tuple[str, ...]] = { def upgrade() -> None: - """Add missing values; rebuild any enum found with uppercase members.""" + """Add missing values; rebuild any enum found with uppercase members. + + This migration is fundamentally introspective — it queries pg_enum at + runtime to find drifted types and rebuild them. There is no + representable equivalent in offline (--sql) mode, so we emit the + additive ALTER TYPE statements only and skip the rebuild branch. + Run against a real DB to perform the reconcile. + """ + if context.is_offline_mode(): + for enum_name, additions in _DESIRED_ADDITIONS.items(): + for value in additions: + op.execute( + f"ALTER TYPE {enum_name} ADD VALUE IF NOT EXISTS '{value}'" + ) + return bind = op.get_bind() # Step 1: find every enum that has at least one uppercase member. diff --git a/alembic/versions/011_drop_quarantined_state.py b/alembic/versions/011_drop_quarantined_state.py index a56c4853..877ff7ba 100644 --- a/alembic/versions/011_drop_quarantined_state.py +++ b/alembic/versions/011_drop_quarantined_state.py @@ -35,7 +35,7 @@ Create Date: 2026-05-03 from __future__ import annotations -from alembic import op +from alembic import context, op from sqlalchemy import text revision = "011_drop_quarantined_state" @@ -65,7 +65,14 @@ _NEW_TASKSTATUS_MEMBERS: tuple[str, ...] = ( def upgrade() -> None: - """Drop `quarantined` from taskstatus by rebuilding the type.""" + """Drop `quarantined` from taskstatus by rebuilding the type. + + Skipped in offline (--sql) mode: the rebuild is fully introspective + (queries pg_attrdef / pg_type / etc.) and has no representable + offline form. Apply against a live DB. + """ + if context.is_offline_mode(): + return bind = op.get_bind() # Step 1: pre-flight. Look up every column that references taskstatus, diff --git a/alembic/versions/012_align_agentrole_team_with_foundation.py b/alembic/versions/012_align_agentrole_team_with_foundation.py new file mode 100644 index 00000000..61ef4342 --- /dev/null +++ b/alembic/versions/012_align_agentrole_team_with_foundation.py @@ -0,0 +1,39 @@ +"""Align postgres agentrole/team enums with foundation/identity. + +Adds any enum value the foundation declares that postgres lacks. Postgres +enum values cannot be removed without a destructive recreation, so the +inverse direction (postgres has extras the foundation lacks) is handled +in foundation by keeping the legacy value (e.g., Team.MARKETING). + +Revision ID: 012_align_agentrole_team_with_foundation +Revises: 011_drop_quarantined_state +Create Date: 2026-05-10 +""" + +from __future__ import annotations + +from alembic import context, op + +revision = "012_align_agentrole_team_with_foundation" +down_revision = "011_drop_quarantined_state" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + if context.is_offline_mode(): + # Offline mode: skip — only runs when connected to a real DB. + return + # Add 'system' to agentrole enum if missing. ALTER TYPE ... ADD VALUE + # IF NOT EXISTS is idempotent in postgres >= 9.6. + op.execute("ALTER TYPE agentrole ADD VALUE IF NOT EXISTS 'system'") + # Note: every foundation Team value is expected to be already in + # postgres (verified by scripts/verify_postgres_enums.py when DB + # access is available). If a future Team is added to foundation, + # add an ALTER TYPE call for it here. + + +def downgrade() -> None: + # Postgres does not support removing enum values without a destructive + # type recreation. This migration is forward-only by design. + pass diff --git a/docker/scripts/post-tool-budget-hook.sh b/docker/scripts/post-tool-budget-hook.sh index 216512cf..f7fa1eb4 100644 --- a/docker/scripts/post-tool-budget-hook.sh +++ b/docker/scripts/post-tool-budget-hook.sh @@ -7,12 +7,16 @@ # Claude sees it in the next turn: # # [Budget] — soft warning (past warn threshold) -# [Loop] — same tool+args ≥ loop_threshold times in the window +# [Loop] — same tool+args ≥ loop_threshold times in the window. +# When the SDK reports loop_action="halt" (foundation default +# BudgetPolicy.loop_action), the hook exits 1 to deny the +# wrapping tool call. Operators can soften with +# ROBOCO_AGENT_LOOP_ACTION=warn. # [Halt] — hard cap breached; orchestrator kill-switch will terminate # the container on its next sweep. Hook also fires the # auto-escalate on the agent's behalf. # -# Non-blocking: exit 0 always (this is a reminder, not a guard). +# Default: exit 0 (reminder). Exit 1 only on loop+halt. set -u @@ -46,6 +50,7 @@ total=$(echo "$resp" | jq -r '.total // 0') warn=$(echo "$resp" | jq -r '.warn // false') halt=$(echo "$resp" | jq -r '.halt // false') loop=$(echo "$resp" | jq -r '.loop // false') +loop_action=$(echo "$resp" | jq -r '.loop_action // "warn"') halt_threshold=$(echo "$resp" | jq -r '.halt_threshold // 150') if [[ "$halt" == "true" ]]; then @@ -55,6 +60,15 @@ if [[ "$halt" == "true" ]]; then # container within agent_budget_sweep_interval_seconds anyway. curl -sf -m 2 -X POST "$SDK_URL/terminal/force_substitute" >/dev/null 2>&1 || true elif [[ "$loop" == "true" ]]; then + if [[ "$loop_action" == "halt" ]]; then + # Foundation BudgetPolicy.loop_action="halt": deny the wrapping tool + # call so the agent cannot keep retrying the same (tool,args) pair. + # Only fires when the SDK explicitly reports loop_action=="halt"; + # if the field is missing (older SDK / partial deploy), falls + # through to the legacy warn-only branch below. + echo "[Loop] Same tool+args repeated in window — halting (BudgetPolicy.loop_action=halt). Escalate via roboco_task_escalate() or substitute." >&2 + exit 1 + fi echo "[Loop] Same tool+args repeated in window. Stop looping — escalate via roboco_task_escalate() or substitute." elif [[ "$warn" == "true" ]]; then echo "[Budget] ${total}/${halt_threshold} tool calls used. Plan your remaining work carefully." diff --git a/docs/rag/lifecycle/intent-verbs.md b/docs/rag/lifecycle/intent-verbs.md new file mode 100644 index 00000000..8f22c414 --- /dev/null +++ b/docs/rag/lifecycle/intent-verbs.md @@ -0,0 +1,213 @@ +# Intent Verbs (gateway-facing surface) + +## claim_doc_task + +Claim awaiting_documentation. Returns evidence inline. + +**Allowed roles:** documenter + +**Composes:** (no atomic actions) + + +## claim_review + +Claim a task in awaiting_qa for review. Returns evidence inline. + +**Allowed roles:** qa + +**Composes:** (no atomic actions) + + +## complete + +Cell PM merges leaf PR + transitions to completed; Main PM merges root PR + escalates to CEO. + +**Allowed roles:** cell_pm, main_pm + +**Composes:** complete + +**Side effects:** pr_merge + + +## delegate + +Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/documentation). + +**Allowed roles:** cell_pm, main_pm + +**Composes:** create_subtask + + +## escalate_to_ceo + +Escalate to CEO with reason. Transitions to awaiting_ceo_approval. + +**Allowed roles:** head_marketing, main_pm, product_owner + +**Composes:** escalate_to_ceo + + +## escalate_up + +Escalate to your role's escalation_target. + +**Allowed roles:** cell_pm, main_pm + +**Composes:** (no atomic actions) + + +## fail_review + +Fail QA with concrete issues. Transitions to needs_revision. + +**Allowed roles:** qa + +**Composes:** qa_fail + + +## give_me_work + +Return your most-actionable task or signal idle. + +**Allowed roles:** cell_pm, developer, documenter, main_pm, qa + +**Composes:** (no atomic actions) + + +## i_am_blocked + +Escalate to PM. Logs a struggle journal entry. + +**Allowed roles:** developer, documenter, qa + +**Composes:** block + + +## i_am_done + +Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit. + +**Allowed roles:** developer + +**Composes:** submit_verification → submit_qa + +**Preconditions:** commits>=1, owns_task + + +## i_am_idle + +Signal you have no active work. PMs auto-pause owned in_progress tasks. + +**Allowed roles:** auditor, cell_pm, developer, documenter, head_marketing, main_pm, product_owner, qa + +**Composes:** (no atomic actions) + + +## i_documented + +Signal docs complete. Transitions to awaiting_pm_review. + +**Allowed roles:** documenter + +**Composes:** docs_complete + + +## i_will_plan + +PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks. + +**Allowed roles:** cell_pm, main_pm + +**Composes:** claim → set_plan → start + +**Preconditions:** plan + + +## i_will_work_on + +Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation. + +**Allowed roles:** developer + +**Composes:** claim → set_plan → start + +**Preconditions:** plan + + +## open_pr + +Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done. + +**Allowed roles:** developer + +**Composes:** (no atomic actions) + +**Side effects:** push_branch, create_pr + +**Preconditions:** commits>=1, no_prior_pr, owns_task + + +## pass_review + +Pass QA. Transitions awaiting_qa -> awaiting_documentation. + +**Allowed roles:** qa + +**Composes:** qa_pass + + +## resume + +Resume a paused task you own. paused -> in_progress. + +**Allowed roles:** cell_pm, developer, documenter, main_pm, qa + +**Composes:** resume + + +## submit_up + +Cell PM bubbles a finished cell-scope task up to Main PM. + +**Allowed roles:** cell_pm + +**Composes:** submit_pm_review + +**Side effects:** create_pr + + +## triage + +List actionable tasks in your scope. + +**Allowed roles:** auditor, cell_pm, head_marketing, main_pm, product_owner + +**Composes:** (no atomic actions) + + +## triage_all + +List actionable tasks across all teams (Main PM only). + +**Allowed roles:** main_pm + +**Composes:** (no atomic actions) + + +## unblock + +PM unblocks a blocked task; restores pre-block state. + +**Allowed roles:** cell_pm, main_pm + +**Composes:** unblock + + +## unclaim + +Voluntarily release a claim back to pending. The work-in-progress branch is preserved. + +**Allowed roles:** cell_pm, developer, documenter, main_pm, qa + +**Composes:** (no atomic actions) + diff --git a/docs/rag/lifecycle/status-transitions.md b/docs/rag/lifecycle/status-transitions.md new file mode 100644 index 00000000..5676bc7c --- /dev/null +++ b/docs/rag/lifecycle/status-transitions.md @@ -0,0 +1,36 @@ +# Status Transitions + +| Source | Target | Action | Roles | +|--------|--------|--------|-------| +| awaiting_ceo_approval | cancelled | cancel | cell_pm, ceo, main_pm | +| awaiting_ceo_approval | completed | ceo_approve | ceo | +| awaiting_ceo_approval | needs_revision | ceo_reject | ceo | +| awaiting_documentation | awaiting_pm_review | docs_complete | documenter | +| awaiting_documentation | cancelled | cancel | cell_pm, ceo, main_pm | +| awaiting_documentation | claimed | claim | documenter | +| awaiting_pm_review | awaiting_ceo_approval | escalate_to_ceo | head_marketing, main_pm, product_owner | +| awaiting_pm_review | cancelled | cancel | cell_pm, ceo, main_pm | +| awaiting_pm_review | completed | complete | cell_pm, main_pm | +| awaiting_qa | awaiting_documentation | qa_pass | qa | +| awaiting_qa | cancelled | cancel | cell_pm, ceo, main_pm | +| awaiting_qa | claimed | claim | qa | +| awaiting_qa | needs_revision | qa_fail | qa | +| backlog | cancelled | cancel | cell_pm, ceo, main_pm | +| backlog | pending | activate | any | +| blocked | cancelled | cancel | cell_pm, ceo, main_pm | +| blocked | in_progress | unblock | any | +| claimed | cancelled | cancel | cell_pm, ceo, main_pm | +| claimed | in_progress | start | any | +| in_progress | awaiting_pm_review | submit_pm_review | any | +| in_progress | blocked | block | any | +| in_progress | cancelled | cancel | cell_pm, ceo, main_pm | +| in_progress | paused | pause | any | +| in_progress | verifying | submit_verification | any | +| needs_revision | cancelled | cancel | cell_pm, ceo, main_pm | +| needs_revision | claimed | claim | any | +| paused | cancelled | cancel | cell_pm, ceo, main_pm | +| paused | in_progress | resume | any | +| pending | cancelled | cancel | cell_pm, ceo, main_pm | +| pending | claimed | claim | any | +| verifying | awaiting_qa | submit_qa | any | +| verifying | cancelled | cancel | cell_pm, ceo, main_pm | diff --git a/panel/lib/lifecycle.json b/panel/lib/lifecycle.json new file mode 100644 index 00000000..73286997 --- /dev/null +++ b/panel/lib/lifecycle.json @@ -0,0 +1,558 @@ +{ + "claim_rules": { + "auditor": [], + "cell_pm": [ + "pending" + ], + "ceo": [], + "developer": [ + "needs_revision", + "pending" + ], + "documenter": [ + "awaiting_documentation", + "pending" + ], + "head_marketing": [], + "main_pm": [ + "pending" + ], + "product_owner": [], + "qa": [ + "awaiting_qa" + ] + }, + "intents": [ + { + "allowed_roles": [ + "documenter" + ], + "composes": [], + "description": "Claim awaiting_documentation. Returns evidence inline.", + "name": "claim_doc_task", + "side_effects": [] + }, + { + "allowed_roles": [ + "qa" + ], + "composes": [], + "description": "Claim a task in awaiting_qa for review. Returns evidence inline.", + "name": "claim_review", + "side_effects": [] + }, + { + "allowed_roles": [ + "cell_pm", + "main_pm" + ], + "composes": [ + "complete" + ], + "description": "Cell PM merges leaf PR + transitions to completed; Main PM merges root PR + escalates to CEO.", + "name": "complete", + "side_effects": [ + "pr_merge" + ] + }, + { + "allowed_roles": [ + "cell_pm", + "main_pm" + ], + "composes": [ + "create_subtask" + ], + "description": "Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/documentation).", + "name": "delegate", + "side_effects": [] + }, + { + "allowed_roles": [ + "head_marketing", + "main_pm", + "product_owner" + ], + "composes": [ + "escalate_to_ceo" + ], + "description": "Escalate to CEO with reason. Transitions to awaiting_ceo_approval.", + "name": "escalate_to_ceo", + "side_effects": [] + }, + { + "allowed_roles": [ + "cell_pm", + "main_pm" + ], + "composes": [], + "description": "Escalate to your role's escalation_target.", + "name": "escalate_up", + "side_effects": [] + }, + { + "allowed_roles": [ + "qa" + ], + "composes": [ + "qa_fail" + ], + "description": "Fail QA with concrete issues. Transitions to needs_revision.", + "name": "fail_review", + "side_effects": [] + }, + { + "allowed_roles": [ + "cell_pm", + "developer", + "documenter", + "main_pm", + "qa" + ], + "composes": [], + "description": "Return your most-actionable task or signal idle.", + "name": "give_me_work", + "side_effects": [] + }, + { + "allowed_roles": [ + "developer", + "documenter", + "qa" + ], + "composes": [ + "block" + ], + "description": "Escalate to PM. Logs a struggle journal entry.", + "name": "i_am_blocked", + "side_effects": [] + }, + { + "allowed_roles": [ + "developer" + ], + "composes": [ + "submit_verification", + "submit_qa" + ], + "description": "Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit.", + "name": "i_am_done", + "side_effects": [] + }, + { + "allowed_roles": [ + "auditor", + "cell_pm", + "developer", + "documenter", + "head_marketing", + "main_pm", + "product_owner", + "qa" + ], + "composes": [], + "description": "Signal you have no active work. PMs auto-pause owned in_progress tasks.", + "name": "i_am_idle", + "side_effects": [] + }, + { + "allowed_roles": [ + "documenter" + ], + "composes": [ + "docs_complete" + ], + "description": "Signal docs complete. Transitions to awaiting_pm_review.", + "name": "i_documented", + "side_effects": [] + }, + { + "allowed_roles": [ + "cell_pm", + "main_pm" + ], + "composes": [ + "claim", + "set_plan", + "start" + ], + "description": "PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.", + "name": "i_will_plan", + "side_effects": [] + }, + { + "allowed_roles": [ + "developer" + ], + "composes": [ + "claim", + "set_plan", + "start" + ], + "description": "Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.", + "name": "i_will_work_on", + "side_effects": [] + }, + { + "allowed_roles": [ + "developer" + ], + "composes": [], + "description": "Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.", + "name": "open_pr", + "side_effects": [ + "push_branch", + "create_pr" + ] + }, + { + "allowed_roles": [ + "qa" + ], + "composes": [ + "qa_pass" + ], + "description": "Pass QA. Transitions awaiting_qa -> awaiting_documentation.", + "name": "pass_review", + "side_effects": [] + }, + { + "allowed_roles": [ + "cell_pm", + "developer", + "documenter", + "main_pm", + "qa" + ], + "composes": [ + "resume" + ], + "description": "Resume a paused task you own. paused -> in_progress.", + "name": "resume", + "side_effects": [] + }, + { + "allowed_roles": [ + "cell_pm" + ], + "composes": [ + "submit_pm_review" + ], + "description": "Cell PM bubbles a finished cell-scope task up to Main PM.", + "name": "submit_up", + "side_effects": [ + "create_pr" + ] + }, + { + "allowed_roles": [ + "auditor", + "cell_pm", + "head_marketing", + "main_pm", + "product_owner" + ], + "composes": [], + "description": "List actionable tasks in your scope.", + "name": "triage", + "side_effects": [] + }, + { + "allowed_roles": [ + "main_pm" + ], + "composes": [], + "description": "List actionable tasks across all teams (Main PM only).", + "name": "triage_all", + "side_effects": [] + }, + { + "allowed_roles": [ + "cell_pm", + "main_pm" + ], + "composes": [ + "unblock" + ], + "description": "PM unblocks a blocked task; restores pre-block state.", + "name": "unblock", + "side_effects": [] + }, + { + "allowed_roles": [ + "cell_pm", + "developer", + "documenter", + "main_pm", + "qa" + ], + "composes": [], + "description": "Voluntarily release a claim back to pending. The work-in-progress branch is preserved.", + "name": "unclaim", + "side_effects": [] + } + ], + "transitions": [ + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "awaiting_ceo_approval", + "target": "cancelled" + }, + { + "action": "ceo_approve", + "roles": [ + "ceo" + ], + "source": "awaiting_ceo_approval", + "target": "completed" + }, + { + "action": "ceo_reject", + "roles": [ + "ceo" + ], + "source": "awaiting_ceo_approval", + "target": "needs_revision" + }, + { + "action": "docs_complete", + "roles": [ + "documenter" + ], + "source": "awaiting_documentation", + "target": "awaiting_pm_review" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "awaiting_documentation", + "target": "cancelled" + }, + { + "action": "claim", + "roles": [ + "documenter" + ], + "source": "awaiting_documentation", + "target": "claimed" + }, + { + "action": "escalate_to_ceo", + "roles": [ + "head_marketing", + "main_pm", + "product_owner" + ], + "source": "awaiting_pm_review", + "target": "awaiting_ceo_approval" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "awaiting_pm_review", + "target": "cancelled" + }, + { + "action": "complete", + "roles": [ + "cell_pm", + "main_pm" + ], + "source": "awaiting_pm_review", + "target": "completed" + }, + { + "action": "qa_pass", + "roles": [ + "qa" + ], + "source": "awaiting_qa", + "target": "awaiting_documentation" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "awaiting_qa", + "target": "cancelled" + }, + { + "action": "claim", + "roles": [ + "qa" + ], + "source": "awaiting_qa", + "target": "claimed" + }, + { + "action": "qa_fail", + "roles": [ + "qa" + ], + "source": "awaiting_qa", + "target": "needs_revision" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "backlog", + "target": "cancelled" + }, + { + "action": "activate", + "roles": null, + "source": "backlog", + "target": "pending" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "blocked", + "target": "cancelled" + }, + { + "action": "unblock", + "roles": null, + "source": "blocked", + "target": "in_progress" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "claimed", + "target": "cancelled" + }, + { + "action": "start", + "roles": null, + "source": "claimed", + "target": "in_progress" + }, + { + "action": "submit_pm_review", + "roles": null, + "source": "in_progress", + "target": "awaiting_pm_review" + }, + { + "action": "block", + "roles": null, + "source": "in_progress", + "target": "blocked" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "in_progress", + "target": "cancelled" + }, + { + "action": "pause", + "roles": null, + "source": "in_progress", + "target": "paused" + }, + { + "action": "submit_verification", + "roles": null, + "source": "in_progress", + "target": "verifying" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "needs_revision", + "target": "cancelled" + }, + { + "action": "claim", + "roles": null, + "source": "needs_revision", + "target": "claimed" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "paused", + "target": "cancelled" + }, + { + "action": "resume", + "roles": null, + "source": "paused", + "target": "in_progress" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "pending", + "target": "cancelled" + }, + { + "action": "claim", + "roles": null, + "source": "pending", + "target": "claimed" + }, + { + "action": "submit_qa", + "roles": null, + "source": "verifying", + "target": "awaiting_qa" + }, + { + "action": "cancel", + "roles": [ + "cell_pm", + "ceo", + "main_pm" + ], + "source": "verifying", + "target": "cancelled" + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 5b3814e7..3424628e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -162,6 +162,10 @@ select = [ "roboco/services/*.py" = ["PLC0415"] "roboco/api/routes/*.py" = ["PLC0415"] "roboco/runtime/*.py" = ["PLC0415"] +# Lifecycle validators: foundation/_validate_lifecycle is imported from the +# bottom of policy/lifecycle.py at module-load time, so the validators must +# defer their inverse imports until call time to avoid a cycle. +"roboco/foundation/_validate_lifecycle.py" = ["PLC0415"] # Test fixtures that reload modules to test env-var-at-import-time behavior "tests/unit/mcp_servers/*.py" = ["PLC0415"] diff --git a/roboco/agent_sdk/models.py b/roboco/agent_sdk/models.py index 56100125..cbcda379 100644 --- a/roboco/agent_sdk/models.py +++ b/roboco/agent_sdk/models.py @@ -6,6 +6,7 @@ Pydantic models for A2A messaging between agents. from datetime import UTC, datetime from enum import StrEnum +from typing import Any, Literal from uuid import UUID, uuid4 from pydantic import BaseModel, Field @@ -95,6 +96,16 @@ class BudgetStatus(BaseModel): halt_threshold: int = Field(default=0) loop_threshold: int = Field(default=0) loop_window: int = Field(default=0) + loop_action: Literal["warn", "halt"] = Field( + default="halt", + description=( + "What the post-tool hook should do when loop=True. " + "'halt' -> hook exits 1 to deny the wrapping tool call; " + "'warn' -> hook prints [Loop] and exits 0 (legacy behaviour). " + "Sourced from foundation.BudgetPolicy.loop_action; env-overridable " + "via ROBOCO_AGENT_LOOP_ACTION." + ), + ) class TerminalToolRecordRequest(BaseModel): @@ -132,3 +143,62 @@ class PostMortemRequest(BaseModel): loop_triggered: bool = Field(default=False) halt_triggered: bool = Field(default=False) reason: str = Field(default="stopped") + + +class VerbAttemptRequest(BaseModel): + """Per-verb circuit-breaker attempt record (Phase 3 Task 14). + + Posted by the agent's response-handling layer when a gateway verb + call returns a rejection envelope (`tracing_gap`, `invalid_state`, + `not_authorized`, `incomplete_input`). Successful (`ok`) calls must + NOT be posted — only rejections count toward the circuit breaker. + """ + + verb: str = Field(..., description="Gateway verb name, e.g. i_am_done") + task_id: str | None = Field( + default=None, + description=( + "Task this verb call was scoped to. None for verbs that operate " + "without a task — those track per-verb only." + ), + ) + rejection_kind: str = Field( + ..., + description=( + "Envelope error kind: tracing_gap | invalid_state | " + "not_authorized | incomplete_input" + ), + ) + + +class VerbCircuitStatus(BaseModel): + """Response from /verb/attempted — breaker state for this (verb, task_id) key.""" + + verb: str = Field(..., description="Verb that was recorded") + task_id: str | None = Field(default=None) + attempts: int = Field( + default=0, + description="Rejections counted in the current 60s window for this key", + ) + limit: int | None = Field( + default=None, + description=( + "Per-verb cap from foundation.retry_limit_for(verb). None means " + "the verb is in UNLIMITED_RETRY_VERBS — the breaker never trips." + ), + ) + window_seconds: int = Field( + default=60, description="Sliding-window size used by the tracker" + ) + open: bool = Field( + default=False, + description="True if attempts >= limit — agent must stop calling this verb", + ) + circuit_envelope: dict[str, Any] | None = Field( + default=None, + description=( + "Populated only when open=True. Wire-format Envelope.circuit_open " + "the SDK consumer should return to the agent in place of the " + "next gateway call." + ), + ) diff --git a/roboco/agent_sdk/server.py b/roboco/agent_sdk/server.py index 1873b976..ffd2edf7 100644 --- a/roboco/agent_sdk/server.py +++ b/roboco/agent_sdk/server.py @@ -14,8 +14,9 @@ Features: import json import os import time -from collections import Counter, deque +from collections import Counter, defaultdict, deque from pathlib import Path +from typing import Any, Literal import httpx import structlog @@ -35,7 +36,12 @@ from roboco.agent_sdk.models import ( SendResponse, TerminalStatus, TerminalToolRecordRequest, + VerbAttemptRequest, + VerbCircuitStatus, ) +from roboco.foundation.policy.agent_loop import DEFAULT_BUDGET as _BUDGET +from roboco.foundation.policy.agent_loop import retry_limit_for +from roboco.services.gateway.envelope import Envelope logger = structlog.get_logger() @@ -524,12 +530,29 @@ async def traceability_remind(tool: str = "") -> dict: # without needing external storage. State resets on container restart, which # matches session lifetime. -_WARN_THRESHOLD = int(os.environ.get("ROBOCO_AGENT_TOOL_CALL_WARN", "50")) -_HALT_THRESHOLD = int(os.environ.get("ROBOCO_AGENT_TOOL_CALL_HALT", "150")) -_LOOP_THRESHOLD = int(os.environ.get("ROBOCO_AGENT_LOOP_THRESHOLD", "3")) -_LOOP_WINDOW = int(os.environ.get("ROBOCO_AGENT_LOOP_WINDOW", "10")) +_WARN_THRESHOLD = int( + os.environ.get("ROBOCO_AGENT_TOOL_CALL_WARN", str(_BUDGET.tool_call_warn_at)) +) +_HALT_THRESHOLD = int( + os.environ.get("ROBOCO_AGENT_TOOL_CALL_HALT", str(_BUDGET.tool_call_halt_at)) +) +_LOOP_THRESHOLD = int( + os.environ.get("ROBOCO_AGENT_LOOP_THRESHOLD", str(_BUDGET.loop_threshold)) +) +_LOOP_WINDOW = int(os.environ.get("ROBOCO_AGENT_LOOP_WINDOW", str(_BUDGET.loop_window))) +_LOOP_ACTION_RAW = os.environ.get("ROBOCO_AGENT_LOOP_ACTION", _BUDGET.loop_action) +_LOOP_ACTION: Literal["warn", "halt"] = "halt" if _LOOP_ACTION_RAW == "halt" else "warn" _STOP_ALLOWANCE = int(os.environ.get("ROBOCO_AGENT_STOP_ATTEMPT_ALLOWANCE", "1")) -_RECENT_TOOL_WINDOW = 5 +_RECENT_TOOL_WINDOW = 5 # not in foundation — keep local +# Sliding-window for the per-verb retry circuit breaker (Phase 3 Task 14). +# 60s matches the docstring on foundation.VERB_RETRY_LIMITS — cap is "N +# rejections in 60s", not "N rejections since session start". +_VERB_ATTEMPT_WINDOW_S: int = 60 +# Rejection envelope kinds that COUNT toward the breaker. Successful (ok) +# calls do not count, by design. +_CIRCUIT_REJECTION_KINDS: frozenset[str] = frozenset( + {"tracing_gap", "invalid_state", "not_authorized", "incomplete_input"} +) _TERMINAL_TOOLS: frozenset[str] = frozenset( { @@ -563,6 +586,15 @@ class _SessionState: self.stop_attempts: int = 0 self.loop_triggered: bool = False self.halt_triggered: bool = False + # Per-verb circuit breaker: maps (verb, task_id) → deque[monotonic + # timestamp]. Pruned to a 60s window on every record/check. Only + # rejection envelopes (tracing_gap / invalid_state / not_authorized + # / incomplete_input) feed into this — successful calls never count. + # task_id may be None for verbs that operate without one (e.g. + # give_me_work) — those keys collapse to (verb, None). + self.verb_attempts: dict[tuple[str, str | None], deque[float]] = defaultdict( + deque + ) def reset(self) -> None: self._init_fields() @@ -589,6 +621,134 @@ class _SessionState: _state = _SessionState() +# ============================================================================= +# PER-VERB CIRCUIT BREAKER (Phase 3 Task 14) +# ============================================================================= +# Pre-Phase-3 the gateway had no per-verb retry cap. The 2026-05-10 smoke +# showed i_am_done retried 5+ times in 2 minutes within the global 150-tool +# budget — the agent never hit a real wall. The tracker here closes that gap: +# (verb, task_id) → deque[timestamp] over a 60s sliding window. When the +# count exceeds foundation.retry_limit_for(verb), the next attempt receives +# Envelope.circuit_open with a remediate hint pointing to i_am_blocked / +# i_am_idle as graceful exits. +# +# Helpers are module-private (leading _) but exported for test access; they +# operate on the live _state singleton, mirroring the budget-tracker pattern. + + +def _prune_verb_window(window: deque[float], now: float) -> None: + """Drop entries older than _VERB_ATTEMPT_WINDOW_S from the deque (in place).""" + cutoff = now - _VERB_ATTEMPT_WINDOW_S + while window and window[0] < cutoff: + window.popleft() + + +def _record_verb_attempt(verb: str, task_id: str | None) -> None: + """Record a verb-level rejection. + + Append the current monotonic timestamp to the (verb, task_id) deque and + prune entries older than the window. Caller must only invoke this for + REJECTION envelopes — counting successful calls would defeat the + breaker's purpose (the agent is allowed to call i_am_done once it + succeeds; only stuck retries should accumulate). + """ + key = (verb, task_id) + now = time.monotonic() + window = _state.verb_attempts[key] + window.append(now) + _prune_verb_window(window, now) + + +def _verb_attempt_count(verb: str, task_id: str | None) -> int: + """Count rejections in the last _VERB_ATTEMPT_WINDOW_S seconds. + + Prunes the underlying deque on read so external observers always see a + fresh count. Returns 0 for keys that have never been recorded. + """ + key = (verb, task_id) + window = _state.verb_attempts.get(key) + if window is None: + return 0 + _prune_verb_window(window, time.monotonic()) + return len(window) + + +def _check_verb_circuit(verb: str, task_id: str | None) -> dict[str, Any] | None: + """Return a circuit_open envelope dict if the breaker should open, else None. + + Lookup order matches foundation.retry_limit_for(): + - Verb in UNLIMITED_RETRY_VERBS → None (never trips) + - Verb in VERB_RETRY_LIMITS → cap is the explicit value + - Otherwise → cap is verb_retry_max_per_minute + """ + limit = retry_limit_for(verb) + if limit is None: + return None + count = _verb_attempt_count(verb, task_id) + if count < limit: + return None + env = Envelope.circuit_open( + verb=verb, + attempts=count, + window_seconds=_VERB_ATTEMPT_WINDOW_S, + remediate=( + f"verb {verb!r} has been rejected {count} times in " + f"{_VERB_ATTEMPT_WINDOW_S}s. Stop retrying. Call " + "i_am_blocked(reason='unable to satisfy gate after N attempts') " + "or i_am_idle() to release the claim. The PM will pick it up." + ), + ) + return env.as_dict() + + +@app.post("/verb/attempted", response_model=VerbCircuitStatus) +async def verb_attempted(req: VerbAttemptRequest) -> VerbCircuitStatus: + """Record a verb-level rejection and report breaker state. + + Posted by the agent's response-handling layer after every gateway call + that returned a rejection envelope. Rejections of unknown kind are + ignored (they don't count) — the catalog of counted kinds lives in + `_CIRCUIT_REJECTION_KINDS`. The response carries the live breaker state + plus, when open, the wire-format `Envelope.circuit_open` to surface to + the agent in place of the next gateway call. + """ + if req.rejection_kind in _CIRCUIT_REJECTION_KINDS: + _record_verb_attempt(req.verb, req.task_id) + limit = retry_limit_for(req.verb) + count = _verb_attempt_count(req.verb, req.task_id) + is_open = limit is not None and count >= limit + envelope_dict = _check_verb_circuit(req.verb, req.task_id) if is_open else None + return VerbCircuitStatus( + verb=req.verb, + task_id=req.task_id, + attempts=count, + limit=limit, + window_seconds=_VERB_ATTEMPT_WINDOW_S, + open=is_open, + circuit_envelope=envelope_dict, + ) + + +@app.get("/verb/circuit_status", response_model=VerbCircuitStatus) +async def verb_circuit_status( + verb: str, task_id: str | None = None +) -> VerbCircuitStatus: + """Read-only breaker state for (verb, task_id) — does NOT record an attempt.""" + limit = retry_limit_for(verb) + count = _verb_attempt_count(verb, task_id) + is_open = limit is not None and count >= limit + envelope_dict = _check_verb_circuit(verb, task_id) if is_open else None + return VerbCircuitStatus( + verb=verb, + task_id=task_id, + attempts=count, + limit=limit, + window_seconds=_VERB_ATTEMPT_WINDOW_S, + open=is_open, + circuit_envelope=envelope_dict, + ) + + @app.post("/budget/tool_called", response_model=BudgetStatus) async def budget_tool_called(req: BudgetToolCalledRequest) -> BudgetStatus: """Record a tool invocation and return current budget status.""" @@ -622,6 +782,7 @@ def _budget_snapshot() -> BudgetStatus: halt_threshold=_HALT_THRESHOLD, loop_threshold=_LOOP_THRESHOLD, loop_window=_LOOP_WINDOW, + loop_action=_LOOP_ACTION, ) diff --git a/roboco/agents/factories/_base.py b/roboco/agents/factories/_base.py index 1b92a33c..f6014584 100644 --- a/roboco/agents/factories/_base.py +++ b/roboco/agents/factories/_base.py @@ -105,6 +105,22 @@ def _autogen_verbs_layer(prompts_path: Path, role: "AgentRole") -> str | None: return _load_layer(prompts_path / "_generated" / f"{role_value}.md") +def _lifecycle_layer(prompts_path: Path, role: "AgentRole") -> str | None: + """Load the canonical lifecycle fragment for this role. + + The file at ``_generated/lifecycle-.md`` is regenerated from + ``roboco/lifecycle/spec.py`` by ``scripts/build_lifecycle_artifacts.py`` + (``make lifecycle``). It lists exactly the verbs the role can invoke + with one-line descriptions — the canonical source of truth that the + gateway enforces. Placing it at the top of the system prompt means + the agent reads its allowed verb surface before anything else. CI + gates on ``make lifecycle && git diff --exit-code`` so this fragment + cannot drift from the spec. + """ + role_value = role.value if hasattr(role, "value") else str(role) + return _load_layer(prompts_path / "_generated" / f"lifecycle-{role_value}.md") + + def compose_prompt( role: "AgentRole", team: "Team | None", @@ -115,11 +131,16 @@ def compose_prompt( Compose a system prompt from layered components. Combines: - 1. base.md - Universal rules (all agents) - 2. roles/{role}.md - Role-specific behavior - 3. _generated/{role}.md - Autogenerated verb-signature table from schemas - 4. teams/{team}.md - Team context (if team is set) - 5. identities/{agent_slug}.md - Agent identity + 1. _generated/lifecycle-{role}.md - Canonical lifecycle verb surface (from spec) + 2. base.md - Universal rules (all agents) + 3. roles/{role}.md - Role-specific behavior + 4. _generated/{role}.md - Autogenerated verb-signature table from schemas + 5. teams/{team}.md - Team context (if team is set) + 6. identities/{agent_slug}.md - Agent identity + + The lifecycle fragment goes first so every agent reads its allowed + verb surface before any other instruction. It is regenerated from + ``roboco/lifecycle/spec.py`` by ``make lifecycle`` and gated on CI. Args: role: Agent's role (developer, qa, pm, documenter, board) @@ -134,6 +155,7 @@ def compose_prompt( parts: list[str] = [] for layer in ( + _lifecycle_layer(prompts_path, role), _load_layer(prompts_path / "base.md"), _role_layer(prompts_path, role), _autogen_verbs_layer(prompts_path, role), diff --git a/roboco/agents_config.py b/roboco/agents_config.py index 9d5f67e4..7687d435 100644 --- a/roboco/agents_config.py +++ b/roboco/agents_config.py @@ -30,6 +30,8 @@ import hmac import os from typing import Final +from roboco.foundation import identity as _foundation +from roboco.foundation.policy import communications as _comms from roboco.models.base import NotificationPriority, NotificationType from roboco.seeds.initial_data import AGENT_UUIDS @@ -104,68 +106,32 @@ def _resolve_to_slug(agent_id: str) -> str: # AGENT ROLE MAPPINGS # ============================================================================= -AGENT_ROLE_MAP: Final[dict[str, str]] = { - # Backend cell - "be-dev-1": "developer", - "be-dev-2": "developer", - "be-qa": "qa", - "be-pm": "cell_pm", - "be-doc": "documenter", - # Frontend cell - "fe-dev-1": "developer", - "fe-dev-2": "developer", - "fe-qa": "qa", - "fe-pm": "cell_pm", - "fe-doc": "documenter", - # UX/UI cell - "ux-dev-1": "developer", - "ux-dev-2": "developer", - "ux-qa": "qa", - "ux-pm": "cell_pm", - "ux-doc": "documenter", - # Management / Board - "main-pm": "main_pm", - "product-owner": "product_owner", - "head-marketing": "head_marketing", - "auditor": "auditor", - "ceo": "ceo", +# Agent catalog data is canonicalized in roboco/foundation/identity.py. +# These string-keyed maps are kept for backwards compatibility with code +# that types role/team as `str` rather than the foundation enums. +# Derived at module load — adding an agent edits foundation/identity.py only. +AGENT_ROLE_MAP: dict[str, str] = { + slug: row.role.value + for slug, row in _foundation.AGENTS.items() + if row.role != _foundation.Role.SYSTEM # exclude sentinel from string-keyed map } - -AGENT_TEAM_MAP: Final[dict[str, str]] = { - # Backend cell - "be-dev-1": "backend", - "be-dev-2": "backend", - "be-qa": "backend", - "be-pm": "backend", - "be-doc": "backend", - # Frontend cell - "fe-dev-1": "frontend", - "fe-dev-2": "frontend", - "fe-qa": "frontend", - "fe-pm": "frontend", - "fe-doc": "frontend", - # UX/UI cell (matches Team.UX_UI = "ux_ui") - "ux-dev-1": "ux_ui", - "ux-dev-2": "ux_ui", - "ux-qa": "ux_ui", - "ux-pm": "ux_ui", - "ux-doc": "ux_ui", - # Management — main-pm gets its own team folder; board members share `board`. - # These match Team.MAIN_PM and Team.BOARD enum values so workspace path - # resolution never produces a literal "None" segment. - "main-pm": "main_pm", - "product-owner": "board", - "head-marketing": "board", - "auditor": "board", - "ceo": "board", +AGENT_TEAM_MAP: dict[str, str] = { + slug: row.team.value + for slug, row in _foundation.AGENTS.items() + if row.role != _foundation.Role.SYSTEM } - -CELL_MEMBERS: Final[dict[str, list[str]]] = { - "backend": ["be-dev-1", "be-dev-2", "be-qa", "be-pm", "be-doc"], - "frontend": ["fe-dev-1", "fe-dev-2", "fe-qa", "fe-pm", "fe-doc"], - "ux_ui": ["ux-dev-1", "ux-dev-2", "ux-qa", "ux-pm", "ux-doc"], +CELL_MEMBERS: dict[str, list[str]] = { + _foundation.Team.BACKEND.value: sorted( + _foundation.slugs_for_team(_foundation.Team.BACKEND) + ), + _foundation.Team.FRONTEND.value: sorted( + _foundation.slugs_for_team(_foundation.Team.FRONTEND) + ), + _foundation.Team.UX_UI.value: sorted( + _foundation.slugs_for_team(_foundation.Team.UX_UI) + ), } @@ -191,14 +157,20 @@ ALL_QA: Final[list[str]] = ["be-qa", "fe-qa", "ux-qa"] ALL_DOCS: Final[list[str]] = ["be-doc", "fe-doc", "ux-doc"] CELL_PMS: Final[list[str]] = ["be-pm", "fe-pm", "ux-pm"] -# PM-capable roles (can create and assign tasks) -PM_ROLES: Final[set[str]] = { - "cell_pm", - "main_pm", - "product_owner", - "head_marketing", - "ceo", -} +# `PM_ROLES` is canonical in foundation.identity (CELL_PM + MAIN_PM only). +# This file's historical 5-role set is renamed to TASK_CREATOR_ROLES — it +# represents "roles that can call task.create", not the PM hierarchy. +# StrEnum members hash like their .value strings, so `role_str in TASK_CREATOR_ROLES` +# still works for str inputs from get_agent_role(). +TASK_CREATOR_ROLES: Final[frozenset[_foundation.Role]] = frozenset( + { + _foundation.Role.CELL_PM, + _foundation.Role.MAIN_PM, + _foundation.Role.PRODUCT_OWNER, + _foundation.Role.HEAD_MARKETING, + _foundation.Role.CEO, + } +) # Escalation chain - who each agent escalates to ESCALATION_CHAIN: Final[dict[str, str]] = { @@ -287,28 +259,23 @@ def is_ceo(agent_id: str) -> bool: def can_send_notifications(agent_id: str) -> bool: - """Check if agent can send notifications (PMs, Board, Auditor, CEO).""" - role = get_agent_role(agent_id) - return role in ( - "cell_pm", - "main_pm", - "product_owner", - "head_marketing", - "auditor", - "ceo", - ) + """Whether this agent's role may call notify(). Canonical in foundation.""" + try: + return _foundation.Role(get_agent_role(agent_id)) in _comms.NOTIFY_SENDER_ROLES + except ValueError: + return False def can_create_tasks(agent_id: str) -> bool: - """Check if agent can create tasks (PMs and management only).""" + """Check if agent can create tasks (PMs, board, and CEO).""" role = get_agent_role(agent_id) - return role in PM_ROLES + return role in TASK_CREATOR_ROLES def can_assign_tasks(agent_id: str) -> bool: - """Check if agent can assign tasks (PMs and management only).""" + """Check if agent can assign tasks (PMs, board, and CEO).""" role = get_agent_role(agent_id) - return role in PM_ROLES + return role in TASK_CREATOR_ROLES # Cancel roles match task_lifecycle.py - CEO and Auditor cannot cancel (they observe) @@ -366,69 +333,66 @@ def get_pm_for_agent(agent_id: str) -> str | None: # ============================================================================= # CHANNEL ACCESS RULES # ============================================================================= +# +# Channel ACL is canonicalized in foundation.policy.communications.CHANNELS. +# This slug-keyed dict-of-string-lists derives from the role-keyed foundation +# data. Adding a channel or changing its membership edits foundation.CHANNELS; +# this dict updates at module load. +# +# Derivation rules: +# - read: roles in (read_roles - silent_roles), filtered by team_scope +# - write: roles in write_roles, filtered by team_scope +# - silent: roles in silent_roles, filtered by team_scope +# Cross-cell roles (MAIN_PM, AUDITOR, CEO, board) are not subject to team_scope; +# only cell-member roles (DEVELOPER/QA/DOCUMENTER/CELL_PM) are filtered. + +# Cell-member roles subject to team_scope filtering. Lifted to module scope so +# tests and downstream consumers can introspect the rule. +_TEAM_SCOPED_ROLES: Final[frozenset[_foundation.Role]] = frozenset( + { + _foundation.Role.DEVELOPER, + _foundation.Role.QA, + _foundation.Role.DOCUMENTER, + _foundation.Role.CELL_PM, + } +) + + +def _slugs_for_role_set( + role_set: frozenset[_foundation.Role], + team_scope: _foundation.Team | None, +) -> list[str]: + """Expand a role-set to sorted agent slugs, honoring optional team_scope. + + A slug qualifies when its role is in `role_set` AND, if its role is in + _TEAM_SCOPED_ROLES and team_scope is set, its team matches team_scope. + The system sentinel is always excluded. + """ + out: list[str] = [] + for slug, row in _foundation.AGENTS.items(): + if slug == "system": + continue + if row.role not in role_set: + continue + if ( + team_scope is not None + and row.role in _TEAM_SCOPED_ROLES + and row.team != team_scope + ): + continue + out.append(slug) + return sorted(out) + CHANNEL_ACCESS: Final[dict[str, dict[str, list[str]]]] = { - # Cell channels - members + main-pm read/write, auditor silent - "backend-cell": { - "read": [*CELL_MEMBERS["backend"], "main-pm"], - "write": [*CELL_MEMBERS["backend"], "main-pm"], - "silent": ["auditor"], - }, - "frontend-cell": { - "read": [*CELL_MEMBERS["frontend"], "main-pm"], - "write": [*CELL_MEMBERS["frontend"], "main-pm"], - "silent": ["auditor"], - }, - "uxui-cell": { - "read": [*CELL_MEMBERS["ux_ui"], "main-pm"], - "write": [*CELL_MEMBERS["ux_ui"], "main-pm"], - "silent": ["auditor"], - }, - # Cross-cell role channels - # Cell members read/write their role channel - # Cell PMs read/write ALL cross-cell channels for coordination - "dev-all": { - "read": [*ALL_DEVS, *ALL_QA, *ALL_DOCS, *CELL_PMS, "main-pm"], - "write": [*ALL_DEVS, *CELL_PMS, "main-pm"], - "silent": ["auditor"], - }, - "qa-all": { - "read": [*ALL_QA, *ALL_DEVS, *ALL_DOCS, *CELL_PMS, "main-pm"], - "write": [*ALL_QA, *CELL_PMS], - "silent": ["auditor"], - }, - "pm-all": { - "read": [*CELL_PMS, "main-pm"], - "write": [*CELL_PMS, "main-pm"], - "silent": ["auditor"], - }, - "doc-all": { - "read": [*ALL_DOCS, *CELL_PMS, "main-pm"], - "write": [*ALL_DOCS, *CELL_PMS], - "silent": ["auditor"], - }, - # Management channels - "main-pm-board": { - "read": ["main-pm", "product-owner", "head-marketing", "auditor"], - "write": ["main-pm", "product-owner", "head-marketing", "auditor"], - "silent": [], - }, - "board-private": { - "read": ["product-owner", "head-marketing", "auditor", "ceo", "main-pm"], - "write": ["product-owner", "head-marketing", "auditor", "ceo"], - "silent": [], - }, - # Broadcast channels - "announcements": { - "read": ALL_AGENTS, - "write": ["main-pm", "product-owner", "head-marketing", "ceo"], - "silent": [], - }, - "all-hands": { - "read": ALL_AGENTS, - "write": ALL_AGENTS, - "silent": [], - }, + slug: { + "read": _slugs_for_role_set( + spec.read_roles - spec.silent_roles, spec.team_scope + ), + "write": _slugs_for_role_set(spec.write_roles, spec.team_scope), + "silent": _slugs_for_role_set(spec.silent_roles, spec.team_scope), + } + for slug, spec in _comms.CHANNELS.items() } @@ -460,50 +424,9 @@ ROLE_PERMISSION_LEVELS: Final[dict[str, str]] = { # ============================================================================= # NOTIFICATION PERMISSIONS # ============================================================================= - -NOTIFICATION_PERMISSIONS: Final[dict[str, dict]] = { - # Cell PMs can notify their own cell members - "cell_pm": { - "can_send": True, - "scope": "cell", - }, - # Main PM can notify anyone - "main_pm": { - "can_send": True, - "scope": "all", - }, - # Board can notify management chain - "product_owner": { - "can_send": True, - "scope": ["main-pm", "head-marketing", "auditor", "ceo"], - }, - "head_marketing": { - "can_send": True, - "scope": ["main-pm", "product-owner", "auditor", "ceo"], - }, - # Auditor can notify anyone - "auditor": { - "can_send": True, - "scope": "all", - }, - # CEO can notify anyone - "ceo": { - "can_send": True, - "scope": "all", - }, - # Developers CANNOT send notifications - "developer": { - "can_send": False, - }, - # QA CANNOT send notifications - "qa": { - "can_send": False, - }, - # Documenters CANNOT send notifications - "documenter": { - "can_send": False, - }, -} +# Sender allowlist now lives in foundation.policy.communications.NOTIFY_SENDER_ROLES. +# Scope rules (cell / all / list) live in services/permissions.py since they +# depend on AgentContext (role + team) — not pure foundation data. VALID_NOTIFICATION_TYPES: Final[frozenset[str]] = frozenset( t.value for t in NotificationType @@ -661,10 +584,10 @@ def get_agent_skills(agent_id: str) -> list[dict]: # - To board: Must go through Main PM # - To CEO: Must go through board -# Roles that can reach each other directly (CEO is human - use notifications) -_BOARD_ROLES: Final[frozenset[str]] = frozenset( - {"product_owner", "head_marketing", "auditor", "main_pm"} -) +# Board roles (PO + Head Marketing + Auditor) — derived from foundation. +# Main PM is intentionally NOT in this set; main_pm is a layer above cells +# but below the board. +_BOARD_ROLES: Final[frozenset[_foundation.Role]] = _foundation.BOARD_ROLES _MAIN_PM_TARGETS: Final[frozenset[str]] = frozenset( {"cell_pm", "main_pm", "product_owner", "head_marketing", "auditor"} ) @@ -731,7 +654,7 @@ def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]: if from_role in ("product_owner", "head_marketing", "auditor"): return ( (True, None) - if to_role in _BOARD_ROLES + if to_role in _BOARD_ROLES or to_role == "main_pm" else (False, f"Board cannot A2A {to_role}s. Route through main-pm.") ) diff --git a/roboco/api/deps.py b/roboco/api/deps.py index 633a5077..9db375cc 100644 --- a/roboco/api/deps.py +++ b/roboco/api/deps.py @@ -19,6 +19,7 @@ from roboco.agents_config import verify_agent_token from roboco.api.schemas.optimal import PaginationParams from roboco.db.base import get_db from roboco.db.tables import AgentTable +from roboco.foundation.identity import BOARD_ROLES, DEV_ROLES, PM_ROLES, Role from roboco.models import AgentRole, Team from roboco.runtime import AgentOrchestrator from roboco.services.a2a import A2AService @@ -340,12 +341,14 @@ CurrentAgentContext = Annotated[AgentContext, Depends(get_agent_context)] # no translation layer needed. # ============================================================================= -_PM_OR_ABOVE_ROLES: frozenset[str] = frozenset( - {"cell_pm", "main_pm", "product_owner", "auditor", "ceo"} -) -_DEVELOPER_OR_ABOVE_ROLES: frozenset[str] = frozenset( - {"developer", "cell_pm", "main_pm", "product_owner", "auditor", "ceo"} +# Role-sets derive from foundation so renaming a role lives in one file. +# HEAD_MARKETING is intentionally excluded from every "above" set — the role is +# a marketing spokesperson, not a workflow approver. StrEnum membership means +# the sets compare equal against both Role.* and the lowercase header string. +_PM_OR_ABOVE_ROLES: frozenset[Role] = ( + PM_ROLES | (BOARD_ROLES - {Role.HEAD_MARKETING}) | {Role.CEO} ) +_DEVELOPER_OR_ABOVE_ROLES: frozenset[Role] = DEV_ROLES | _PM_OR_ABOVE_ROLES def _role_value(role: Any) -> str: @@ -371,9 +374,10 @@ def require_developer_or_above(role: Any, action: str) -> None: ) -_GLOBAL_CELL_ACCESS_ROLES: frozenset[str] = frozenset( - {"main_pm", "product_owner", "auditor", "ceo"} -) +_GLOBAL_CELL_ACCESS_ROLES: frozenset[Role] = (BOARD_ROLES - {Role.HEAD_MARKETING}) | { + Role.MAIN_PM, + Role.CEO, +} def require_cell_access(agent: AgentContext, cell: Team, action: str) -> None: diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index b6efa45a..20c39fd4 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -42,6 +42,7 @@ from roboco.api.schemas.tasks import ( transform_update_data, ) from roboco.exceptions import TaskLifecycleError +from roboco.foundation.policy import task_completeness as tc from roboco.models.base import AgentRole, TaskStatus, Team from roboco.models.task import TaskCreate from roboco.services.audit import get_audit_service @@ -118,22 +119,23 @@ async def create_task( # `data.project_id` is `UUID` (required) on TaskCreate, so pydantic # rejects missing/null values with 422 before this handler runs. - # Acceptance criteria required — without them, QA has nothing to - # verify and the task is structurally unclosable. - if not data.acceptance_criteria or not any( - (c or "").strip() for c in data.acceptance_criteria - ): + # Defense-in-depth completeness check. TaskCreate's Pydantic schema + # already enforces the structural rules in TASK_AT_CREATE (min_length + # on title/description/acceptance_criteria; the discriminator enums + # for task_type/nature/estimated_complexity/team are required). What + # Pydantic does NOT catch are the denylist phrases — placeholder ACs + # like "completed and reviewed by assignee" or stub descriptions — + # because those are well-formed strings. Re-running the canonical + # checker here catches them at the route boundary, so route, schema, + # and service all share one notion of "complete". + completeness = tc.check(tc.TASK_AT_CREATE, data) + if not completeness.passed: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "error": { - "code": "ACCEPTANCE_CRITERIA_REQUIRED", - "message": ( - "Tasks must include at least one non-empty " - "acceptance criterion — it's what QA verifies." - ), - "hint": "Pass acceptance_criteria=['...', '...'].", - } + "error": "incomplete_input", + "missing": completeness.missing, + "field_hints": completeness.field_hints, }, ) diff --git a/roboco/api/routes/v2/_role_dep.py b/roboco/api/routes/v2/_role_dep.py index 6593124b..89ae022e 100644 --- a/roboco/api/routes/v2/_role_dep.py +++ b/roboco/api/routes/v2/_role_dep.py @@ -11,16 +11,20 @@ from typing import TYPE_CHECKING, Annotated, Any, cast from fastapi import Depends, Header, HTTPException, params, status +from roboco.foundation.identity import Role + if TYPE_CHECKING: from fastapi import Request from roboco.services.gateway.envelope import Envelope -def _require_roles(allowed: frozenset[str]) -> params.Depends: +def _require_roles(allowed: frozenset[Role]) -> params.Depends: def _check( x_agent_role: Annotated[str, Header(alias="X-Agent-Role")], ) -> None: + # `Role` is a StrEnum, so the lowercase header string compares equal + # to its matching member. if x_agent_role.lower() not in allowed: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -30,13 +34,16 @@ def _require_roles(allowed: frozenset[str]) -> params.Depends: return cast("params.Depends", Depends(_check)) -require_dev = _require_roles(frozenset({"developer"})) -require_qa = _require_roles(frozenset({"qa"})) -require_doc = _require_roles(frozenset({"documenter"})) -require_cell_pm = _require_roles(frozenset({"cell_pm"})) -require_main_pm = _require_roles(frozenset({"main_pm"})) -require_board = _require_roles(frozenset({"product_owner", "head_marketing"})) -require_auditor = _require_roles(frozenset({"auditor"})) +# Role-typed single-role guards — renaming a role edits foundation.identity only. +# `require_board` is the only multi-role guard (Product Owner + Head of Marketing +# share the public-facing board endpoints; the auditor has its own guard). +require_dev = _require_roles(frozenset({Role.DEVELOPER})) +require_qa = _require_roles(frozenset({Role.QA})) +require_doc = _require_roles(frozenset({Role.DOCUMENTER})) +require_cell_pm = _require_roles(frozenset({Role.CELL_PM})) +require_main_pm = _require_roles(frozenset({Role.MAIN_PM})) +require_board = _require_roles(frozenset({Role.PRODUCT_OWNER, Role.HEAD_MARKETING})) +require_auditor = _require_roles(frozenset({Role.AUDITOR})) def envelope_to_response(env: Envelope, request: Request) -> dict[str, Any]: diff --git a/roboco/api/routes/v2/flow_cell_pm.py b/roboco/api/routes/v2/flow_cell_pm.py index 69b04f02..f58555f9 100644 --- a/roboco/api/routes/v2/flow_cell_pm.py +++ b/roboco/api/routes/v2/flow_cell_pm.py @@ -68,6 +68,7 @@ async def delegate( assigned_to=body.assigned_to, team=body.team, task_type=body.task_type, + nature=body.nature, acceptance_criteria=body.acceptance_criteria, estimated_complexity=body.estimated_complexity, ) diff --git a/roboco/api/routes/v2/flow_main_pm.py b/roboco/api/routes/v2/flow_main_pm.py index f6602bea..fe8c7e8f 100644 --- a/roboco/api/routes/v2/flow_main_pm.py +++ b/roboco/api/routes/v2/flow_main_pm.py @@ -68,6 +68,7 @@ async def delegate( assigned_to=body.assigned_to, team=body.team, task_type=body.task_type, + nature=body.nature, acceptance_criteria=body.acceptance_criteria, estimated_complexity=body.estimated_complexity, ) diff --git a/roboco/api/schemas/tasks.py b/roboco/api/schemas/tasks.py index e479cb3b..b7a3039a 100644 --- a/roboco/api/schemas/tasks.py +++ b/roboco/api/schemas/tasks.py @@ -9,7 +9,7 @@ from datetime import datetime from typing import Any from uuid import UUID, uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from sqlalchemy import inspect as sa_inspect from sqlalchemy import select @@ -177,13 +177,18 @@ class CommitRefInput(BaseModel): class TaskUpdate(BaseModel): """Request to update a task. - CEO can update any field. All fields are optional for partial updates. + CEO can update any field. All fields are optional for partial updates, + but when present they must satisfy the foundation completeness rules + appropriate to their lifecycle moment. Notably, acceptance_criteria + cannot be set to [] or None — that would blank the criteria post- + creation, violating the Golden Rule "no task without acceptance + criteria". """ # Basic info - title: str | None = None - description: str | None = None - acceptance_criteria: list[str] | None = None + title: str | None = Field(default=None, min_length=1, max_length=200) + description: str | None = Field(default=None, min_length=20) + acceptance_criteria: list[str] | None = Field(default=None, min_length=1) priority: int | None = Field(default=None, ge=0, le=3) target_date: datetime | None = None estimated_complexity: Complexity | None = None @@ -213,6 +218,27 @@ class TaskUpdate(BaseModel): auditor_notes: str | None = None quick_context: str | None = None + @model_validator(mode="before") + @classmethod + def _reject_explicit_blank_acceptance_criteria(cls, data: Any) -> Any: + """If acceptance_criteria is in the payload at all, it must be non-empty. + + Pydantic's `Field(default=None, min_length=1)` does not reject the + explicit `None` case because `None` matches the `Optional` type + annotation. This validator fills the gap so PATCH cannot be used + to blank the criteria post-creation (Golden Rule preservation). + """ + if isinstance(data, dict) and "acceptance_criteria" in data: + value = data["acceptance_criteria"] + if value is None or (isinstance(value, list) and len(value) == 0): + raise ValueError( + "acceptance_criteria cannot be blanked via PATCH " + "(Golden Rule: no task without acceptance criteria). " + "Omit the field if you don't want to change it; pass " + "a non-empty list to replace it." + ) + return data + # ============================================================================= # RESPONSE MODELS diff --git a/roboco/api/schemas/v2/flow.py b/roboco/api/schemas/v2/flow.py index 568d42b1..db144400 100644 --- a/roboco/api/schemas/v2/flow.py +++ b/roboco/api/schemas/v2/flow.py @@ -94,18 +94,32 @@ class IWillPlanRequest(BaseModel): class DelegateRequest(BaseModel): + """HTTP body for cell_pm + main_pm `delegate` verbs. + + Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE` + so under-filled payloads fail at the request boundary with a 422 — no + silent defaults, no "code"/"medium" fallbacks. Each constraint matches + the hint string returned by the foundation policy. + """ + parent_task_id: UUID - title: str = Field(..., min_length=1) - description: str = Field(..., min_length=1) + title: str = Field(..., min_length=1, max_length=200) + # 20-char minimum mirrors TASK_AT_CREATE.description (MIN_LENGTH=20). + # Forces a real one-line summary instead of "x" or "see title". + description: str = Field(..., min_length=20) assigned_to: str = Field(..., min_length=1) team: str = Field(..., min_length=1) - # task_type is REQUIRED. The 2026-05-08 trace showed agents omitting - # it and the old default of 'code' deadlocking the lifecycle. Force - # callers to declare intent: code | documentation | research | - # planning | design | administrative. + # task_type, nature, estimated_complexity are EXPLICITLY_DECLARED in + # TASK_AT_CREATE. The 2026-05-08 trace showed agents omitting task_type + # and the old default of 'code' deadlocking the lifecycle; the same + # silent-default trap exists for nature ("technical") and complexity + # ("medium"). Force callers to declare intent. task_type: str = Field(..., min_length=1) - acceptance_criteria: list[str] | None = None - estimated_complexity: str = "medium" + nature: str = Field(..., min_length=1) + estimated_complexity: str = Field(..., min_length=1) + # acceptance_criteria is required and non-empty; downstream policy + # also denylist-checks each item against placeholder phrases. + acceptance_criteria: list[str] = Field(..., min_length=1) class SubmitUpRequest(BaseModel): diff --git a/roboco/enforcement/__init__.py b/roboco/enforcement/__init__.py index 95ef60be..ef442828 100644 --- a/roboco/enforcement/__init__.py +++ b/roboco/enforcement/__init__.py @@ -37,11 +37,6 @@ from roboco.enforcement.journal_perms import ( get_readable_journals, validate_journal_access, ) -from roboco.enforcement.notification_perms import ( - NotificationPermissionError, - get_notification_scope, - validate_notification_permission, -) from roboco.enforcement.task_lifecycle import ( ROLE_RESTRICTED_TRANSITIONS, VALID_TRANSITIONS, @@ -71,7 +66,6 @@ __all__ = [ "GitContext", "GitRequirementError", "JournalAccessDeniedError", - "NotificationPermissionError", "TaskLifecycleError", "TaskOwnershipError", "can_agent_transition", @@ -79,7 +73,6 @@ __all__ = [ "can_review_task", "get_a2a_allowed_targets", "get_agent_channels", - "get_notification_scope", "get_readable_journals", "get_valid_transitions", "is_active_state", @@ -89,7 +82,6 @@ __all__ = [ "validate_channel_access", "validate_git_requirements", "validate_journal_access", - "validate_notification_permission", "validate_task_ownership", "validate_task_transition", ] diff --git a/roboco/enforcement/journal_perms.py b/roboco/enforcement/journal_perms.py index 718ebe30..5544cb57 100644 --- a/roboco/enforcement/journal_perms.py +++ b/roboco/enforcement/journal_perms.py @@ -1,21 +1,43 @@ -""" -Journal Permission Enforcement +"""Journal Permission Enforcement. -Validates who can read whose journal entries. -Permission model mirrors notification/channel access: -- Cell members can read each other's journals (full access including private) -- Cell PMs can read other cells' journals -- Main PM can read all cell journals -- Board can read all journals except CEO/Auditor -- Auditor has silent read access to all journals -- CEO can read all journals +Read-tier rules are canonical in :mod:`roboco.foundation.policy.journaling`. +This module translates the foundation tiers (`ROLE_READ_TIERS`, +`PROTECTED_JOURNALS`) onto the project's existing public surface +(`can_read_journal`, `validate_journal_access`, `get_readable_journals`, +`JournalAccessDeniedError`). + +Permission model (now derived from foundation tiers): +- Self-read is always allowed. +- Protected journals (ceo, auditor) can only be read by `ReadTier.ALL` + roles (CEO, Auditor) — even other "global" readers are excluded. +- `ReadTier.ALL_CELLS` (Main PM, Product Owner, Head of Marketing) reads + every non-protected journal. +- `ReadTier.CELL_AND_PMS` (Cell PM) reads same-cell members and other PMs. +- `ReadTier.CELL` (Developer, QA, Documenter) reads same-cell members only. +- `ReadTier.OWN` (System sentinel, unknown roles) reads nothing but their own. """ +from __future__ import annotations + from roboco.agents_config import ( get_agent_cell, get_agent_role, ) from roboco.exceptions import RobocoError +from roboco.foundation.identity import Role +from roboco.foundation.policy.journaling import ( + PROTECTED_JOURNALS, + ROLE_READ_TIERS, + ReadTier, +) + +__all__ = [ + "PROTECTED_JOURNALS", + "JournalAccessDeniedError", + "can_read_journal", + "get_readable_journals", + "validate_journal_access", +] class JournalAccessDeniedError(RobocoError): @@ -39,105 +61,97 @@ class JournalAccessDeniedError(RobocoError): ) -# Protected journals - only readable by CEO/Auditor themselves -PROTECTED_JOURNALS = frozenset(["ceo", "auditor"]) +def _resolve_role(role_str: str) -> Role | None: + """Map a string role (as returned by `get_agent_role`) to the Role enum. + + Returns None for unknown / sentinel roles so callers can deny by default. + """ + try: + return Role(role_str) + except ValueError: + return None -def _is_same_cell(agent1: str, agent2: str) -> bool: - """Check if two agents are in the same cell.""" - cell1 = get_agent_cell(agent1) - cell2 = get_agent_cell(agent2) +def _tier_for(role_str: str) -> ReadTier: + """Read tier for a given role string. Unknown roles get OWN (deny).""" + role = _resolve_role(role_str) + if role is None: + return ReadTier.OWN + return ROLE_READ_TIERS.get(role, ReadTier.OWN) + + +def _is_same_cell(reader_id: str, owner_id: str) -> bool: + cell1 = get_agent_cell(reader_id) + cell2 = get_agent_cell(owner_id) return cell1 is not None and cell1 == cell2 -# Roles with global read access (can read all non-protected journals) -GLOBAL_READERS = frozenset( - ["ceo", "auditor", "product_owner", "head_marketing", "main_pm"] -) - -# Roles that can read cross-cell PM journals -PM_ROLES = frozenset(["cell_pm", "main_pm"]) - -# Cell member roles (can only read same-cell journals) -CELL_MEMBER_ROLES = frozenset(["developer", "qa", "documenter"]) +def _is_pm_role(role_str: str) -> bool: + role = _resolve_role(role_str) + return role in (Role.CELL_PM, Role.MAIN_PM) -def _check_protected_access( - reader_role: str, owner_id: str, owner_role: str -) -> tuple[bool, str] | None: - """Check access to protected journals. Returns None if not protected.""" - if owner_id not in PROTECTED_JOURNALS and owner_role not in ("ceo", "auditor"): - return None # Not a protected journal - if reader_role in ("ceo", "auditor"): +def _decide_protected(tier: ReadTier, owner_role_str: str) -> tuple[bool, str]: + """Access decision when the target journal is protected.""" + if tier == ReadTier.ALL: return True, "OK" - return False, f"Cannot read {owner_role}'s journal - protected" + return False, f"Cannot read {owner_role_str}'s journal - protected" -def _check_cell_pm_access( - reader_id: str, owner_id: str, owner_role: str +def _decide_by_tier( + tier: ReadTier, + *, + same_cell: bool, + owner_is_pm: bool, ) -> tuple[bool, str]: - """Check Cell PM's access to another journal.""" - if _is_same_cell(reader_id, owner_id): + """Access decision for non-protected journals, dispatched by tier.""" + if tier in (ReadTier.ALL, ReadTier.ALL_CELLS): return True, "OK" - if owner_role in PM_ROLES: - return True, "OK" - return False, "Cell PM can only read journals of cell members, other PMs" - - -def _check_cell_member_access(reader_id: str, owner_id: str) -> tuple[bool, str]: - """Check cell member's access to another journal.""" - if _is_same_cell(reader_id, owner_id): - return True, "OK" - return False, "You can only read journals of your cell members" + if tier == ReadTier.CELL_AND_PMS: + if same_cell or owner_is_pm: + return True, "OK" + return False, "Cell PM can only read journals of cell members, other PMs" + if tier == ReadTier.CELL: + if same_cell: + return True, "OK" + return False, "You can only read journals of your cell members" + return False, "Unknown role - access denied" def can_read_journal(reader_id: str, owner_id: str) -> tuple[bool, str]: - """ - Check if reader can access owner's journal. + """Check if `reader_id` can access `owner_id`'s journal. - Returns: - Tuple of (can_read, reason) + Returns a `(can_read, reason)` tuple. The reason is a human-readable + string suitable for surfacing in error envelopes. """ if reader_id == owner_id: return True, "OK" - reader_role = get_agent_role(reader_id) - owner_role = get_agent_role(owner_id) + reader_role_str = get_agent_role(reader_id) + owner_role_str = get_agent_role(owner_id) + tier = _tier_for(reader_role_str) - # Check protected journals first - if ( - result := _check_protected_access(reader_role, owner_id, owner_role) - ) is not None: - return result - - # Global readers can access all non-protected journals - if reader_role in GLOBAL_READERS: - return True, "OK" - - # Cell PM access rules - if reader_role == "cell_pm": - return _check_cell_pm_access(reader_id, owner_id, owner_role) - - # Cell member access rules - if reader_role in CELL_MEMBER_ROLES: - return _check_cell_member_access(reader_id, owner_id) - - return False, "Unknown role - access denied" + if owner_id in PROTECTED_JOURNALS or owner_role_str in ("ceo", "auditor"): + return _decide_protected(tier, owner_role_str) + return _decide_by_tier( + tier, + same_cell=_is_same_cell(reader_id, owner_id), + owner_is_pm=_is_pm_role(owner_role_str), + ) def validate_journal_access(reader_id: str, owner_id: str) -> bool: - """ - Validate reader can access owner's journal. + """Validate reader can access owner's journal. Args: reader_id: The agent trying to read (slug) owner_id: The journal owner (slug) Returns: - True if allowed + True if allowed. Raises: - JournalAccessDeniedError: If access denied + JournalAccessDeniedError: If access denied. """ can_read, reason = can_read_journal(reader_id, owner_id) if not can_read: @@ -150,37 +164,34 @@ def validate_journal_access(reader_id: str, owner_id: str) -> bool: def get_readable_journals(reader_id: str) -> dict: - """ - Get information about what journals an agent can read. + """Describe what journals an agent can read. - Returns: - Dict with scope information + The returned dict's `scope` field is one of `all`, `all_cells`, + `cell_plus_pms`, `cell`, or `none` — kept for public API parity + with the pre-foundation surface. """ - role = get_agent_role(reader_id) + role_str = get_agent_role(reader_id) cell = get_agent_cell(reader_id) + tier = _tier_for(role_str) - if role in ("ceo", "auditor"): + if tier == ReadTier.ALL: return {"scope": "all", "description": "Can read all journals"} - - if role in ("product_owner", "head_marketing", "main_pm"): + if tier == ReadTier.ALL_CELLS: return { "scope": "all_cells", "description": "Can read all cell journals", - "excludes": ["ceo", "auditor"], + "excludes": list(PROTECTED_JOURNALS), } - - if role == "cell_pm": + if tier == ReadTier.CELL_AND_PMS: return { "scope": "cell_plus_pms", "cell": cell, "description": f"Can read {cell} cell journals and other PM journals", } - - if role in ("developer", "qa", "documenter"): + if tier == ReadTier.CELL: return { "scope": "cell", "cell": cell, "description": f"Can read {cell} cell journals only", } - return {"scope": "none", "description": "Unknown role"} diff --git a/roboco/enforcement/notification_perms.py b/roboco/enforcement/notification_perms.py deleted file mode 100644 index 0fb40d2b..00000000 --- a/roboco/enforcement/notification_perms.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Notification Permission Enforcement - -Validates who can send notifications to whom. -Only PMs, Board, and Auditor can send notifications. -""" - -from roboco.agents_config import ( - NOTIFICATION_PERMISSIONS, - get_agent_cell, - get_agent_role, -) -from roboco.exceptions import RobocoError - - -class NotificationPermissionError(RobocoError): - """Raised when an agent doesn't have permission to send a notification.""" - - def __init__( - self, - sender_id: str, - recipient_id: str | None = None, - message: str | None = None, - ): - self.sender_id = sender_id - self.recipient_id = recipient_id - super().__init__( - code="NOTIFICATION_PERMISSION_DENIED", - message=message or f"Agent {sender_id} cannot send notifications", - details={ - "sender_id": sender_id, - "recipient_id": recipient_id, - }, - ) - - -def _can_send_to_recipient(sender_id: str, recipient_id: str) -> tuple[bool, str]: - """ - Check if sender can send notification to a specific recipient. - - Returns: - Tuple of (can_send, reason) - """ - role = get_agent_role(sender_id) - permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False}) - - can_send = False - reason = "" - - if not permissions.get("can_send", False): - reason = f"Agents with role '{role}' cannot send notifications" - else: - scope = permissions.get("scope", []) - - if scope == "all": - can_send = True - reason = "OK" - elif scope == "cell": - sender_cell = get_agent_cell(sender_id) - recipient_cell = get_agent_cell(recipient_id) - recipient_role = get_agent_role(recipient_id) - - if (sender_cell and sender_cell == recipient_cell) or recipient_role in { - "main_pm", - "cell_pm", - }: - can_send = True - reason = "OK" - else: - reason = ( - "Cell PM can only notify cell members, Main PM, or other Cell PMs" - ) - elif isinstance(scope, list) and recipient_id in scope: - can_send = True - reason = "OK" - else: - reason = f"Cannot send notifications to {recipient_id}" - - return can_send, reason - - -def validate_notification_permission( - sender_id: str, - recipients: list[str], -) -> bool: - """ - Validate sender can notify all recipients. - - Args: - sender_id: The sending agent - recipients: List of recipient agent IDs - - Returns: - True if allowed - - Raises: - NotificationPermissionError: If permission denied - """ - role = get_agent_role(sender_id) - permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False}) - - # First check if sender can send at all - if not permissions.get("can_send", False): - raise NotificationPermissionError( - sender_id=sender_id, - message=f"Agents with role '{role}' cannot send notifications. " - "Only PMs, Board members, and Auditor can send notifications.", - ) - - # Then check each recipient - for recipient_id in recipients: - can_send, reason = _can_send_to_recipient(sender_id, recipient_id) - if not can_send: - raise NotificationPermissionError( - sender_id=sender_id, - recipient_id=recipient_id, - message=reason, - ) - - return True - - -def get_notification_scope(agent_id: str) -> dict: - """ - Get the notification scope for an agent. - - Returns: - Dict with can_send and scope information - """ - role = get_agent_role(agent_id) - return NOTIFICATION_PERMISSIONS.get(role, {"can_send": False}) diff --git a/roboco/enforcement/task_lifecycle.py b/roboco/enforcement/task_lifecycle.py index aefbb16c..001e974c 100644 --- a/roboco/enforcement/task_lifecycle.py +++ b/roboco/enforcement/task_lifecycle.py @@ -1,194 +1,181 @@ +"""Backwards-compatibility shim — view of roboco.foundation.policy.lifecycle. + +The canonical lifecycle / permissions tables live in +:mod:`roboco.foundation.policy.lifecycle`. This module is a thin view +over that data for legacy callers that still import +``VALID_TRANSITIONS`` or ``ROLE_RESTRICTED_TRANSITIONS`` by name. New +code should import from :mod:`roboco.foundation.policy.lifecycle` +directly. + +Symbols still owned by this module (not yet absorbed into the spec): + * Git-workflow gates (:class:`GitContext`, + :class:`GitRequirementError`, :func:`validate_git_requirements`, + :func:`check_parallel_completion`). + * SLA tables (:data:`ROLE_STATE_SLA_KEYS`, :func:`sla_seconds_for`). + +Everything else is derived from the spec. """ -Task Lifecycle State Machine Enforcement -Validates task state transitions follow the defined lifecycle. - -Git Integration (all tasks follow git workflow): - - awaiting_documentation → awaiting_pm_review: - requires BOTH docs_complete AND pr_created - - awaiting_pm_review → awaiting_ceo_approval: - PR should exist (pr_number set) - - awaiting_ceo_approval → completed: PR should be merged (CEO merges) - -See validate_git_requirements() for enforcement. -""" +from __future__ import annotations from dataclasses import dataclass from roboco.config import settings from roboco.exceptions import TaskLifecycleError +from roboco.foundation.policy.lifecycle import _STATUS_TRANSITIONS, STATUS_GRAPH, Status -# Re-export from exceptions for backward compatibility __all__ = [ + "ROLE_RESTRICTED_TRANSITIONS", + "ROLE_STATE_SLA_KEYS", "VALID_TRANSITIONS", "GitContext", "GitRequirementError", "TaskLifecycleError", + "can_agent_transition", "check_parallel_completion", + "get_valid_transitions", + "is_active_state", + "is_terminal_state", + "is_waiting_state", + "sla_seconds_for", "validate_git_requirements", "validate_task_transition", ] # ============================================================================= -# VALID STATE TRANSITIONS -# ============================================================================= - -VALID_TRANSITIONS: dict[str, list[str]] = { - # PM setup phase - task with dependencies or needs session setup - "backlog": ["pending", "cancelled"], - # Ready for work state - "pending": ["claimed", "cancelled"], - # Claimed - can start, unclaim, or cancel - "claimed": ["in_progress", "pending", "cancelled"], - # In progress - can block, pause, verify, submit for PM review, complete, - # cancel, OR drop back to pending via voluntary unclaim / reaper sweep. - # QA direct assignment: QA can also pass/fail when assigned directly. - # Pre-P2-4 the reaper used raw SQL to bypass this list; routing through - # _validate_and_set_status now means the canonical state machine sees the - # actual production transitions. - "in_progress": [ - "blocked", - "paused", - "pending", # voluntary unclaim or reaper sweep - "verifying", - "awaiting_pm_review", - "awaiting_documentation", # QA pass when assigned directly - "needs_revision", # QA fail when assigned directly - "completed", - "cancelled", - ], - # Blocked - can unblock back to in_progress or cancel - "blocked": ["in_progress", "cancelled"], - # Paused - can resume back to in_progress or cancel - "paused": ["in_progress", "cancelled"], - # Verifying - self verification, can go to QA, revision, or skip to docs - "verifying": [ - "awaiting_qa", - "needs_revision", - "awaiting_documentation", - "cancelled", - ], - # Needs revision - developer claims, works, or PM cancels - "needs_revision": ["claimed", "in_progress", "cancelled"], - # Awaiting QA - QA claims, passes, fails, or blocks - "awaiting_qa": [ - "claimed", - "awaiting_documentation", - "needs_revision", - "blocked", - "cancelled", - ], - # Awaiting documentation - documenter claims or marks done - "awaiting_documentation": ["claimed", "awaiting_pm_review", "cancelled"], - # Awaiting PM review - PM claims, then escalates to CEO or completes directly - "awaiting_pm_review": [ - "claimed", - "awaiting_ceo_approval", # Escalate to CEO for final approval - "completed", # PM can complete non-escalated tasks - "needs_revision", # PM sends back to dev for rework (pm_reject) - "cancelled", - ], - # Awaiting CEO approval - CEO makes final decision on major tasks - "awaiting_ceo_approval": [ - "completed", # CEO approves and merges - "needs_revision", # CEO requests changes - "cancelled", # CEO cancels - ], - # Terminal states - cannot transition out - "completed": [], - "cancelled": [], -} - -# ============================================================================= -# ROLE-BASED TRANSITION RESTRICTIONS -# ============================================================================= - -# Roles that can cancel tasks -_CANCEL_ROLES = ["cell_pm", "main_pm", "product_owner", "head_marketing"] - -# CEO is the only role that can approve final merges -_CEO_ROLE = ["ceo"] - -# Transitions that require specific roles -ROLE_RESTRICTED_TRANSITIONS: dict[tuple[str, str], list[str]] = { - # Only PM can activate tasks from backlog - ("backlog", "pending"): _CANCEL_ROLES, - # Only QA can claim and perform QA actions - ("awaiting_qa", "claimed"): ["qa"], - ("awaiting_qa", "awaiting_documentation"): ["qa"], - ("awaiting_qa", "needs_revision"): ["qa"], - # QA direct assignment: QA can pass/fail from in_progress when directly assigned - ("in_progress", "awaiting_documentation"): ["qa"], # QA pass - ("in_progress", "needs_revision"): ["qa"], # QA fail - # Developer cannot self-fail out of verifying (would create a dead-end - # where a dev skips QA and routes themselves back to revision). Only - # QA or PM roles can send a verifying task to revision. - ("verifying", "needs_revision"): ["qa", *_CANCEL_ROLES], - # Only documenter can claim docs tasks - ("awaiting_documentation", "claimed"): ["documenter"], - # Parallel completion: either documenter or developer can trigger transition - # (whoever finishes their work last triggers the transition) - ("awaiting_documentation", "awaiting_pm_review"): ["documenter", "developer"], - # Only PM can claim PM review tasks - ("awaiting_pm_review", "claimed"): _CANCEL_ROLES, - # Only PM can complete tasks (either after PM review or their own work) - ("awaiting_pm_review", "completed"): _CANCEL_ROLES, - ("in_progress", "completed"): _CANCEL_ROLES, # PM completing their own task - # PM, QA, Documenter can submit for PM review (not developers) - ("in_progress", "awaiting_pm_review"): [*_CANCEL_ROLES, "qa", "documenter"], - # Only PM can escalate to CEO approval - ("awaiting_pm_review", "awaiting_ceo_approval"): _CANCEL_ROLES, - # PM rejects back to dev for rework (needs_revision). - ("awaiting_pm_review", "needs_revision"): _CANCEL_ROLES, - # CEO approval transitions - only CEO can act - ("awaiting_ceo_approval", "completed"): _CEO_ROLE, # CEO approves and merges - ("awaiting_ceo_approval", "needs_revision"): _CEO_ROLE, # CEO requests changes - ("awaiting_ceo_approval", "cancelled"): _CEO_ROLE, # CEO cancels - # Only PM or higher can cancel tasks (all states that allow cancel) - ("backlog", "cancelled"): _CANCEL_ROLES, - ("pending", "cancelled"): _CANCEL_ROLES, - ("claimed", "cancelled"): _CANCEL_ROLES, - ("in_progress", "cancelled"): _CANCEL_ROLES, - ("blocked", "cancelled"): _CANCEL_ROLES, - ("paused", "cancelled"): _CANCEL_ROLES, - ("verifying", "cancelled"): _CANCEL_ROLES, - ("needs_revision", "cancelled"): _CANCEL_ROLES, - ("awaiting_qa", "cancelled"): _CANCEL_ROLES, - ("awaiting_documentation", "cancelled"): _CANCEL_ROLES, - ("awaiting_pm_review", "cancelled"): _CANCEL_ROLES, -} - - -# ============================================================================= -# TIME-IN-STATE SLAs (soft guardrail for stuck-task sweep) +# SPEC-DERIVED LEGACY VIEWS # ============================================================================= # -# Keys are (role, status) tuples. Values are seconds. The orchestrator's -# stuck-task sweep reads this table and auto-escalates / auto-releases tasks -# that exceed the SLA for their current owner's role. Absence from the table -# means "no per-role SLA; only the generic 10-minute pending-task sweep -# applies." Defaults live in roboco.config.Settings so they're overridable -# per environment without a code change. +# `VALID_TRANSITIONS` mirrors the legacy `dict[str, list[str]]` shape +# (str keys, str-list values) so existing callers keep working without +# import churn. The data flows from `STATUS_GRAPH` (which is the +# canonical source of truth in `roboco.foundation.policy.lifecycle`). +# +# `_LEGACY_OPERATIONAL_EDGES` are transitions the runtime exercises today +# that the spec has not yet absorbed (unclaim / reaper sweep / PM-direct +# completes / parallel-doc-PR developer trigger). They are out-of-scope +# for the canonical spec — the gateway intent verbs do not compose them +# — but TaskService still calls `_validate_and_set_status` for these +# edges. They live here, clearly fenced, until those callers are +# rewritten to dispatch via the spec; at that point this constant +# becomes empty and the file collapses to a pure view. +# +# `ROLE_RESTRICTED_TRANSITIONS` is the subset of `_STATUS_TRANSITIONS` +# that explicitly pin a role gate at the transition level. Transitions +# whose `role_constraint is None` defer to the action's `allowed_roles` +# table (handled by the gateway, not the legacy enforcement helpers), +# so they are NOT part of this view. Legacy operational edges are not +# role-gated here either — their authority lives in the calling +# service method. -ROLE_STATE_SLA_KEYS: dict[tuple[str, str], str] = { - ("developer", "in_progress"): "agent_sla_developer_in_progress", - ("developer", "verifying"): "agent_sla_developer_verifying", - ("qa", "claimed"): "agent_sla_qa_claimed", - ("documenter", "claimed"): "agent_sla_documenter_claimed", - ("cell_pm", "claimed"): "agent_sla_cell_pm_claimed", +_LEGACY_OPERATIONAL_EDGES: dict[Status, frozenset[Status]] = { + # Voluntary unclaim + reaper sweep (TaskService.unclaim*, + # AgentOrchestrator._reconcile_with_service): an agent or the + # reaper releases a task back to the pool. + Status.CLAIMED: frozenset({Status.PENDING}), + Status.IN_PROGRESS: frozenset( + { + Status.PENDING, # reaper sweep / voluntary unclaim + Status.COMPLETED, # PM completing their own (non-PR) task + # QA acting via direct assignment (no awaiting_qa hop): + Status.AWAITING_DOCUMENTATION, + Status.NEEDS_REVISION, + } + ), + # Self-fail out of verifying (QA / PM only — role gate enforced + # in ROLE_RESTRICTED_TRANSITIONS below). + Status.VERIFYING: frozenset({Status.NEEDS_REVISION, Status.AWAITING_DOCUMENTATION}), + # QA can park a task as blocked while waiting on dev clarification. + Status.AWAITING_QA: frozenset({Status.BLOCKED}), + # PM claim + PM reject path on review queue. + Status.AWAITING_PM_REVIEW: frozenset({Status.CLAIMED, Status.NEEDS_REVISION}), + # Re-entry from revision back into active dev work (without re-claim). + Status.NEEDS_REVISION: frozenset({Status.IN_PROGRESS}), +} + +# Role pins for legacy operational edges. Same shape as the spec-derived +# ROLE_RESTRICTED_TRANSITIONS table; merged in below. +_LEGACY_ROLE_GATES: dict[tuple[Status, Status], tuple[str, ...]] = { + # Direct QA when assigned without going through awaiting_qa. + (Status.IN_PROGRESS, Status.AWAITING_DOCUMENTATION): ("qa",), + (Status.IN_PROGRESS, Status.NEEDS_REVISION): ("qa",), + # PM completing their own work. + (Status.IN_PROGRESS, Status.COMPLETED): ( + "cell_pm", + "head_marketing", + "main_pm", + "product_owner", + ), + # PM claim of review queue. + (Status.AWAITING_PM_REVIEW, Status.CLAIMED): ( + "cell_pm", + "head_marketing", + "main_pm", + "product_owner", + ), + # PM reject back to dev (needs_revision). + (Status.AWAITING_PM_REVIEW, Status.NEEDS_REVISION): ( + "cell_pm", + "head_marketing", + "main_pm", + "product_owner", + ), + # Verifying self-fail — QA + PM only, dev cannot self-route to revision. + (Status.VERIFYING, Status.NEEDS_REVISION): ( + "cell_pm", + "head_marketing", + "main_pm", + "product_owner", + "qa", + ), + # Parallel doc/PR completion: spec pins this to DOCUMENTER (the + # canonical "docs_complete" trigger), but the runtime also calls it + # with role="developer" via TaskService.mark_pr_created when the dev + # creates the PR last. Both are legitimate triggers in the + # parallel-completion phase; override the spec gate here to keep the + # runtime path open until the developer trigger is folded into the + # spec as a sibling action. + (Status.AWAITING_DOCUMENTATION, Status.AWAITING_PM_REVIEW): ( + "developer", + "documenter", + ), } -def sla_seconds_for(role: str | None, status: str) -> int | None: - """Return the configured SLA for (role, status), or None if none applies.""" - if not role: - return None - key = ROLE_STATE_SLA_KEYS.get((role, status)) - if key is None: - return None - value = getattr(settings, key, None) - return int(value) if isinstance(value, int) else None +def _build_valid_transitions() -> dict[str, list[str]]: + merged: dict[str, set[str]] = { + src.value: {t.value for t in STATUS_GRAPH.get(src, frozenset())} + for src in Status + } + for src, extras in _LEGACY_OPERATIONAL_EDGES.items(): + merged[src.value].update(t.value for t in extras) + return {src: sorted(targets) for src, targets in merged.items()} + + +def _build_role_restricted_transitions() -> dict[tuple[str, str], tuple[str, ...]]: + out: dict[tuple[str, str], tuple[str, ...]] = { + (t.source.value, t.target.value): tuple( + sorted(r.value for r in t.role_constraint) + ) + for t in _STATUS_TRANSITIONS + if t.role_constraint is not None + } + for (src, tgt), roles in _LEGACY_ROLE_GATES.items(): + out[(src.value, tgt.value)] = roles + return out + + +VALID_TRANSITIONS: dict[str, list[str]] = _build_valid_transitions() + +ROLE_RESTRICTED_TRANSITIONS: dict[tuple[str, str], tuple[str, ...]] = ( + _build_role_restricted_transitions() +) + + +# ============================================================================= +# LIFECYCLE PREDICATES (DERIVED FROM SPEC) +# ============================================================================= def validate_task_transition( @@ -196,19 +183,14 @@ def validate_task_transition( target_status: str, agent_role: str | None = None, ) -> bool: - """ - Validate task state transition is allowed. + """Validate a task state transition against the spec-derived view. - Args: - current_status: Current task status - target_status: Target task status - agent_role: Optional agent role for role-based restrictions - - Returns: - True if transition is valid - - Raises: - TaskLifecycleError: If transition is invalid or role not permitted + Mirrors the legacy contract: returns ``True`` on success, raises + :class:`TaskLifecycleError` on rejection. Role gates are checked + only against transitions explicitly pinned in + :data:`ROLE_RESTRICTED_TRANSITIONS` — gates that derive from the + triggering action's ``allowed_roles`` are enforced upstream by the + gateway / choreographer, not here. """ valid = VALID_TRANSITIONS.get(current_status, []) @@ -219,18 +201,15 @@ def validate_task_transition( valid_transitions=valid, ) - # Check role-based restrictions if role provided if agent_role: - transition_key = (current_status, target_status) - allowed_roles = ROLE_RESTRICTED_TRANSITIONS.get(transition_key) - + allowed_roles = ROLE_RESTRICTED_TRANSITIONS.get((current_status, target_status)) if allowed_roles and agent_role not in allowed_roles: raise TaskLifecycleError( current_status=current_status, target_status=target_status, message=( f"Role '{agent_role}' cannot perform this transition. " - f"Allowed roles: {allowed_roles}" + f"Allowed roles: {list(allowed_roles)}" ), ) @@ -242,14 +221,7 @@ def can_agent_transition( target_status: str, agent_role: str, ) -> bool: - """ - Check if an agent with given role can perform a transition. - - Non-raising version of validate_task_transition for checking permissions. - - Returns: - True if transition is allowed for the agent - """ + """Non-raising variant of :func:`validate_task_transition`.""" try: return validate_task_transition(current_status, target_status, agent_role) except TaskLifecycleError: @@ -257,25 +229,17 @@ def can_agent_transition( def get_valid_transitions(current_status: str) -> list[str]: - """ - Get list of valid transitions from current status. - - Args: - current_status: Current task status - - Returns: - List of valid target statuses - """ + """Return the list of valid target statuses from ``current_status``.""" return VALID_TRANSITIONS.get(current_status, []) def is_terminal_state(status: str) -> bool: - """Check if a status is a terminal state.""" + """True if ``status`` is a terminal state (no outgoing transitions).""" return status in ("completed", "cancelled") def is_waiting_state(status: str) -> bool: - """Check if a status is a waiting state (agent can work on other tasks).""" + """True if ``status`` parks an agent waiting on someone else.""" return status in ( "blocked", "paused", @@ -287,10 +251,42 @@ def is_waiting_state(status: str) -> bool: def is_active_state(status: str) -> bool: - """Check if a status is an active working state.""" + """True if ``status`` is an active working state.""" return status in ("claimed", "in_progress", "verifying", "needs_revision") +# ============================================================================= +# TIME-IN-STATE SLAs (soft guardrail for stuck-task sweep) +# ============================================================================= +# +# Keys are (role, status) tuples. Values are setting names on +# :data:`roboco.config.settings`. The orchestrator's stuck-task sweep +# reads this table and auto-escalates / auto-releases tasks that exceed +# the SLA for their current owner's role. Absence from the table means +# "no per-role SLA; only the generic 10-minute pending-task sweep +# applies." Defaults live in :class:`roboco.config.Settings` so they're +# overridable per environment without a code change. + +ROLE_STATE_SLA_KEYS: dict[tuple[str, str], str] = { + ("developer", "in_progress"): "agent_sla_developer_in_progress", + ("developer", "verifying"): "agent_sla_developer_verifying", + ("qa", "claimed"): "agent_sla_qa_claimed", + ("documenter", "claimed"): "agent_sla_documenter_claimed", + ("cell_pm", "claimed"): "agent_sla_cell_pm_claimed", +} + + +def sla_seconds_for(role: str | None, status: str) -> int | None: + """Return the configured SLA for ``(role, status)``, or ``None``.""" + if not role: + return None + key = ROLE_STATE_SLA_KEYS.get((role, status)) + if key is None: + return None + value = getattr(settings, key, None) + return int(value) if isinstance(value, int) else None + + # ============================================================================= # GIT INTEGRATION VALIDATION # ============================================================================= @@ -326,40 +322,25 @@ def validate_git_requirements( target_status: str, git_ctx: GitContext | None = None, ) -> bool: + """Validate git-related preconditions for a task transition. + + Enforced gates: + + * ``awaiting_documentation -> awaiting_pm_review`` requires both + ``docs_complete=True`` and ``pr_created=True`` (the documenter and + developer work in parallel; both must finish). + * ``awaiting_pm_review -> awaiting_ceo_approval`` requires + ``pr_number`` to be set (the PR must exist for CEO review). + * ``claimed -> in_progress`` requires ``branch_name`` (auto-created + on claim). + + Passing ``git_ctx=None`` short-circuits the check (no validation). """ - Validate git-related requirements for task transitions. - - All tasks follow git workflow: - - - awaiting_documentation → awaiting_pm_review: - Requires BOTH docs_complete=True AND pr_created=True - (Documenter and Developer work in parallel) - - - awaiting_pm_review → awaiting_ceo_approval: - Requires pr_number to be set (PR exists) - - - claimed → in_progress: - Should have branch_name set (auto-created on claim) - - Args: - current_status: Current task status - target_status: Target task status - git_ctx: Git context with workflow state - - Returns: - True if all requirements met - - Raises: - GitRequirementError: If git requirements not met - """ - # No context means no validation needed (context not provided) if git_ctx is None: return True transition = (current_status, target_status) - # awaiting_documentation → awaiting_pm_review - # Requires BOTH docs AND PR to be ready (parallel workflow) if transition == ("awaiting_documentation", "awaiting_pm_review"): if not git_ctx.docs_complete: raise GitRequirementError( @@ -384,7 +365,6 @@ def validate_git_requirements( ), ) - # awaiting_pm_review → awaiting_ceo_approval: PR should exist for review is_ceo_escalation = transition == ("awaiting_pm_review", "awaiting_ceo_approval") if is_ceo_escalation and git_ctx.pr_number is None: raise GitRequirementError( @@ -397,8 +377,6 @@ def validate_git_requirements( ), ) - # claimed → in_progress - # Should have a branch ready if transition == ("claimed", "in_progress") and not git_ctx.branch_name: raise GitRequirementError( transition=transition, @@ -414,20 +392,5 @@ def validate_git_requirements( def check_parallel_completion(docs_complete: bool, pr_created: bool) -> bool: - """ - Check if parallel execution in awaiting_documentation is complete. - - During awaiting_documentation: - - Documenter works on docs (sets docs_complete=True) - - Developer creates PR (sets pr_created=True) - - Both must be true to transition to awaiting_pm_review. - - Args: - docs_complete: Whether documenter finished - pr_created: Whether developer created PR - - Returns: - True if ready to transition to awaiting_pm_review - """ + """Return True iff the parallel doc+PR phase is fully complete.""" return docs_complete and pr_created diff --git a/roboco/foundation/__init__.py b/roboco/foundation/__init__.py new file mode 100644 index 00000000..0074b590 --- /dev/null +++ b/roboco/foundation/__init__.py @@ -0,0 +1,50 @@ +"""RoboCo foundation package — single source of truth for cross-cutting policy. + +Sub-packages: + - identity: Role, Team, AgentRow, AGENTS, role-sets, lookups + - policy: per-domain policy modules (task_completeness, ...) + +See docs/superpowers/specs/2026-05-10-foundation-canonicalization-design.md. +""" + +from roboco.foundation.identity import ( + AGENTS, + ALL_ROLES, + BOARD_ROLES, + DEV_ROLES, + PM_ROLES, + ROLE_LEVEL, + AgentRow, + Role, + RoleLevel, + Team, + agent_for_slug, + role_for_slug, + slugs_for_role, + slugs_for_team, + team_for_slug, +) + +__all__ = [ + "AGENTS", + "ALL_ROLES", + "BOARD_ROLES", + "DEV_ROLES", + "PM_ROLES", + "ROLE_LEVEL", + "AgentRow", + "Role", + "RoleLevel", + "Team", + "agent_for_slug", + "role_for_slug", + "slugs_for_role", + "slugs_for_team", + "team_for_slug", +] + +# Run validators at import. If foundation tables are inconsistent, the +# orchestrator container won't start — which is correct. +from roboco.foundation._validate import run_all as _run_foundation_validators + +_run_foundation_validators() diff --git a/roboco/foundation/_generators.py b/roboco/foundation/_generators.py new file mode 100644 index 00000000..a2eaa8c3 --- /dev/null +++ b/roboco/foundation/_generators.py @@ -0,0 +1,141 @@ +"""Render canonical lifecycle artifacts (markdown, JSON, prompt fragments). + +Output is deterministic - two calls with the same spec produce the +same bytes. Consumed by `scripts/build_lifecycle_artifacts.py` which +writes the rendered artifacts to disk; CI gate `make lifecycle && +git diff --exit-code` ensures the on-disk artifacts always match +the current spec. +""" + +from __future__ import annotations + +import json +from typing import Any + +from roboco.foundation.policy.lifecycle import ( + _INTENT_VERBS, + _STATUS_TRANSITIONS, + CLAIM_RULES, + IntentSpec, + Role, + StatusTransition, +) + + +def _composes_line(iv: IntentSpec) -> str: + if iv.composes: + return f"**Composes:** {' → '.join(iv.composes)}\n" + return "**Composes:** (no atomic actions)\n" + + +def _intent_verb_section(iv: IntentSpec) -> list[str]: + """Render a single intent-verb section as markdown lines.""" + roles = sorted(r.value for r in iv.allowed_roles) + section = [ + f"## {iv.name}\n", + f"{iv.description}\n", + f"**Allowed roles:** {', '.join(roles)}\n", + _composes_line(iv), + ] + if iv.side_effects: + section.append(f"**Side effects:** {', '.join(iv.side_effects)}\n") + if iv.extra_preconditions: + keys = sorted(p.key for p in iv.extra_preconditions) + section.append(f"**Preconditions:** {', '.join(keys)}\n") + section.append("") + return section + + +def render_intent_verbs_md() -> str: + """One section per intent verb. Description + allowed_roles + composes.""" + lines = ["# Intent Verbs (gateway-facing surface)\n"] + for name in sorted(_INTENT_VERBS): + lines.extend(_intent_verb_section(_INTENT_VERBS[name])) + return "\n".join(lines) + + +def _transition_roles_str(t: StatusTransition) -> str: + if t.role_constraint: + return ", ".join(sorted(r.value for r in t.role_constraint)) + return "any" + + +def _transition_sort_key(t: StatusTransition) -> tuple[str, str, str]: + return (t.source.value, t.target.value, t.triggered_by_action) + + +def render_status_transitions_md() -> str: + """Mirror of pre-gateway STATUS_TRANSITIONS.md - table view.""" + lines = [ + "# Status Transitions", + "", + "| Source | Target | Action | Roles |", + "|--------|--------|--------|-------|", + ] + for t in sorted(_STATUS_TRANSITIONS, key=_transition_sort_key): + lines.append( + f"| {t.source.value} | {t.target.value} | " + f"{t.triggered_by_action} | {_transition_roles_str(t)} |" + ) + return "\n".join(lines) + "\n" + + +def _intent_to_panel_dict(iv: IntentSpec) -> dict[str, Any]: + return { + "name": iv.name, + "description": iv.description, + "allowed_roles": sorted(r.value for r in iv.allowed_roles), + "composes": list(iv.composes), + "side_effects": list(iv.side_effects), + } + + +def _transition_to_panel_dict(t: StatusTransition) -> dict[str, Any]: + roles = sorted(r.value for r in t.role_constraint) if t.role_constraint else None + return { + "source": t.source.value, + "target": t.target.value, + "action": t.triggered_by_action, + "roles": roles, + } + + +def render_panel_json() -> str: + """JSON dump for the panel UI's lifecycle visualizer.""" + payload = { + "intents": [ + _intent_to_panel_dict(iv) + for iv in sorted(_INTENT_VERBS.values(), key=lambda x: x.name) + ], + "transitions": [ + _transition_to_panel_dict(t) + for t in sorted(_STATUS_TRANSITIONS, key=_transition_sort_key) + ], + "claim_rules": { + r.value: sorted(s.value for s in statuses) + for r, statuses in sorted(CLAIM_RULES.items(), key=lambda x: x[0].value) + }, + } + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def render_agent_prompt_fragment(role_value: str) -> str: + """Per-role prompt fragment listing the verbs the agent can call. + + Injected at the top of every agent's system prompt so the agent's + perception of "what verbs exist" matches what the gateway actually + accepts. + """ + role = Role(role_value) + intents = [iv for iv in _INTENT_VERBS.values() if role in iv.allowed_roles] + intents.sort(key=lambda iv: iv.name) + lines = [ + f"# Verbs available to your role ({role.value})", + "", + "These are the only verbs the gateway will accept from you. Calling any", + "other verb will be rejected with a Decision telling you the right one.", + "", + ] + for iv in intents: + lines.append(f"- **{iv.name}**: {iv.description}") + return "\n".join(lines) + "\n" diff --git a/roboco/foundation/_validate.py b/roboco/foundation/_validate.py new file mode 100644 index 00000000..cee4f126 --- /dev/null +++ b/roboco/foundation/_validate.py @@ -0,0 +1,92 @@ +"""Foundation cross-table validators. Run at import time. + +If any validator raises, the orchestrator container won't start — +which is the correct behavior for a misconfigured foundation. + +This module owns the **identity** validators (AGENTS, roles, role +levels). Lifecycle-spec validators live in +``roboco.foundation._validate_lifecycle`` because the lifecycle spec +imports from foundation at module load — placing the lifecycle +checks alongside the identity checks would create an import cycle +between ``roboco.foundation`` and ``roboco.foundation.policy.lifecycle``. + +Roles excluded from "must have at least one agent": Role.SYSTEM +(sentinel-only). All other validators apply to every role. +""" + +from __future__ import annotations + +from collections import Counter + +from roboco.foundation import identity + +_SENTINEL_ROLES: frozenset[identity.Role] = frozenset({identity.Role.SYSTEM}) + + +class IdentityValidationError(RuntimeError): + """Raised at import time when foundation/identity tables are inconsistent.""" + + +def _check_unique_uuids() -> None: + counts = Counter(row.uuid for row in identity.AGENTS.values()) + dupes = {uuid for uuid, n in counts.items() if n > 1} + if dupes: + raise IdentityValidationError( + f"duplicate UUID in AGENTS: {sorted(map(str, dupes))}" + ) + + +def _check_unique_slugs() -> None: + """Dict guarantees this; validator catches accidental mutation.""" + if len(identity.AGENTS) != len(set(identity.AGENTS)): + raise IdentityValidationError( + "AGENTS has duplicate slugs (impossible via dict, but checked anyway)" + ) + + +def _check_every_real_role_has_agent() -> None: + """Every Role.X (except SYSTEM) must have at least one agent.""" + roles_with_agents = {row.role for row in identity.AGENTS.values()} + missing = set(identity.Role) - roles_with_agents - _SENTINEL_ROLES + if missing: + names = ", ".join(sorted(r.value for r in missing)) + raise IdentityValidationError(f"roles with no agent in AGENTS: {names}") + + +def _check_role_level_covers_all_roles() -> None: + missing = set(identity.Role) - set(identity.ROLE_LEVEL) + if missing: + names = ", ".join(sorted(r.value for r in missing)) + raise IdentityValidationError(f"ROLE_LEVEL missing entries for: {names}") + + +def _check_pm_roles_have_agents() -> None: + for role in identity.PM_ROLES: + if not identity.slugs_for_role(role): + raise IdentityValidationError( + f"Role.{role.name} is in PM_ROLES but no agent has it" + ) + + +def _check_board_roles_have_agents() -> None: + for role in identity.BOARD_ROLES: + if not identity.slugs_for_role(role): + raise IdentityValidationError( + f"Role.{role.name} is in BOARD_ROLES but no agent has it" + ) + + +_VALIDATORS = ( + _check_unique_uuids, + _check_unique_slugs, + _check_every_real_role_has_agent, + _check_role_level_covers_all_roles, + _check_pm_roles_have_agents, + _check_board_roles_have_agents, +) + + +def run_all() -> None: + """Run every identity validator. First failure raises IdentityValidationError.""" + for validator in _VALIDATORS: + validator() diff --git a/roboco/foundation/_validate_lifecycle.py b/roboco/foundation/_validate_lifecycle.py new file mode 100644 index 00000000..f5bc32ee --- /dev/null +++ b/roboco/foundation/_validate_lifecycle.py @@ -0,0 +1,295 @@ +"""Import-time self-consistency checks for the lifecycle spec. + +Lives in its own module (separate from ``foundation/_validate.py``) so +``roboco.foundation.__init__`` can import the identity validators +without dragging the lifecycle spec into its import graph. + +Every check below must pass before ``roboco.foundation.policy.lifecycle`` +is importable — a failed validator raises LifecycleSpecError and prevents +the orchestrator container from starting. There is no recovery path: +a bad spec is a build error, not a runtime error. + +Imports of ``roboco.foundation.policy.lifecycle`` are deliberately +deferred to function bodies. The spec module imports this module at the +bottom of its own definition (`_run_all_lifecycle_validators()`); if any +top-level import here referenced the spec module, Python would loop back +into the partially-initialised spec module and the bottom-call's import +of this module's ``run_all_lifecycle_validators`` would fail. The +deferred-import pattern breaks that cycle: this module loads with no +references to the spec, and only resolves them when the validators +actually run. + +The per-file PLC0415 exemption in pyproject.toml exists for this exact +reason. +""" + +from __future__ import annotations + +from collections import deque +from itertools import pairwise +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from roboco.foundation.policy.lifecycle import Status + + +class LifecycleSpecError(RuntimeError): + """Raised at import time when the lifecycle spec is internally inconsistent.""" + + +def reachable_from(start: Status) -> set[Status]: + """All statuses reachable from `start` via STATUS_GRAPH (BFS).""" + from roboco.foundation.policy.lifecycle import STATUS_GRAPH + + seen: set[Status] = {start} + queue: deque[Status] = deque([start]) + while queue: + node = queue.popleft() + for nxt in STATUS_GRAPH.get(node, frozenset()): + if nxt not in seen: + seen.add(nxt) + queue.append(nxt) + return seen + + +def _check_status_enum_coverage() -> None: + """Every Status appears in STATUS_GRAPH (as key).""" + from roboco.foundation.policy.lifecycle import STATUS_GRAPH, Status + + missing = set(Status) - set(STATUS_GRAPH) + if missing: + missing_values = sorted(s.value for s in missing) + raise LifecycleSpecError( + f"Statuses missing from STATUS_GRAPH keys: {missing_values}" + ) + + +def _check_status_reachability() -> None: + """Every Status except BACKLOG (pre-PENDING stash) reachable from PENDING.""" + from roboco.foundation.policy.lifecycle import Status + + reachable = reachable_from(Status.PENDING) + expected = set(Status) - {Status.BACKLOG} + unreachable = expected - reachable + if unreachable: + unreachable_values = sorted(s.value for s in unreachable) + raise LifecycleSpecError( + f"Statuses unreachable from PENDING: {unreachable_values}" + ) + + +def _check_terminal_exits() -> None: + """Every non-terminal status exits to either COMPLETED or CANCELLED.""" + from roboco.foundation.policy.lifecycle import Status + + terminals = {Status.COMPLETED, Status.CANCELLED} + non_terminal = set(Status) - terminals + for s in non_terminal: + reachable = reachable_from(s) + if not (reachable & terminals): + raise LifecycleSpecError( + f"Status '{s.value}' has no path to COMPLETED or CANCELLED" + ) + + +def _check_intent_compositions() -> None: + """Every IntentSpec.composes references existing ActionSpec names. + + Empty composes is allowed: a verb may be purely imperative (e.g. + `unclaim`, `escalate_up`) and dispatch to the service layer rather + than composing atomic actions. There are no action references to + check in that case. + """ + from roboco.foundation.policy.lifecycle import _ATOMIC_ACTIONS, _INTENT_VERBS + + for name, iv in _INTENT_VERBS.items(): + for action_name in iv.composes: + if action_name not in _ATOMIC_ACTIONS: + raise LifecycleSpecError( + f"Intent '{name}' composes unknown action '{action_name}'" + ) + + +def _check_intent_chains() -> None: + """For multi-step intents, the source/target statuses chain. + + For each adjacent pair (act_n, act_{n+1}) in composes, every status + where act_n is allowed must transition to a status where act_{n+1} + is allowed. + """ + from roboco.foundation.policy.lifecycle import _ATOMIC_ACTIONS, _INTENT_VERBS + + for name, iv in _INTENT_VERBS.items(): + for prev, nxt in pairwise(iv.composes): + prev_spec = _ATOMIC_ACTIONS[prev] + nxt_spec = _ATOMIC_ACTIONS[nxt] + if prev_spec.target_status is None: + continue # no transition — chaining doesn't apply + if prev_spec.target_status not in nxt_spec.source_statuses: + required = sorted(s.value for s in nxt_spec.source_statuses) + raise LifecycleSpecError( + f"Intent '{name}': action '{prev}' targets" + f" '{prev_spec.target_status.value}' but next action" + f" '{nxt}' requires {required}" + ) + + +def _check_claim_rules_role_coverage() -> None: + """Every Role in CLAIM_RULES exists in the Role enum.""" + from roboco.foundation.policy.lifecycle import CLAIM_RULES, Role + + unknown_roles = set(CLAIM_RULES) - set(Role) + if unknown_roles: + raise LifecycleSpecError( + f"CLAIM_RULES has unknown roles: {sorted(r for r in unknown_roles)}" + ) + + +def _check_claim_rules_status_coverage() -> None: + """Every Status in CLAIM_RULES.values() exists and is non-terminal.""" + from roboco.foundation.policy.lifecycle import CLAIM_RULES, Status + + terminals = {Status.COMPLETED, Status.CANCELLED} + for role, statuses in CLAIM_RULES.items(): + bad = statuses & frozenset(terminals) + if bad: + raise LifecycleSpecError( + f"CLAIM_RULES[{role.value}] includes terminal status:" + f" {sorted(s.value for s in bad)}" + ) + + +def _check_self_review_symmetry() -> None: + """qa_pass / qa_fail / docs_complete must agree on self_review_block.""" + from roboco.foundation.policy.lifecycle import _ATOMIC_ACTIONS + + qp = _ATOMIC_ACTIONS["qa_pass"].self_review_block + qf = _ATOMIC_ACTIONS["qa_fail"].self_review_block + dc = _ATOMIC_ACTIONS["docs_complete"].self_review_block + if not (qp == qf == dc): + raise LifecycleSpecError( + f"self_review_block asymmetry:" + f" qa_pass={qp}, qa_fail={qf}, docs_complete={dc}" + ) + + +def _check_role_team_rules_slugs() -> None: + """Every slug in ROLE_TEAM_RULES exists in seeded AGENT_UUIDS.""" + from roboco.foundation.policy.lifecycle import ROLE_TEAM_RULES + from roboco.seeds.initial_data import AGENT_UUIDS + + unknown = set(ROLE_TEAM_RULES) - set(AGENT_UUIDS) + if unknown: + raise LifecycleSpecError( + f"ROLE_TEAM_RULES references unseeded slugs: {sorted(unknown)}" + ) + + +def _check_status_transitions_actions() -> None: + """Every StatusTransition.triggered_by_action references a real ActionSpec.""" + from roboco.foundation.policy.lifecycle import _ATOMIC_ACTIONS, _STATUS_TRANSITIONS + + for t in _STATUS_TRANSITIONS: + if t.triggered_by_action not in _ATOMIC_ACTIONS: + raise LifecycleSpecError( + f"StatusTransition {t.source.value}→{t.target.value} triggered by" + f" unknown action '{t.triggered_by_action}'" + ) + + +def _check_action_target_reachable_from_source() -> None: + """For every ActionSpec with target_status set, the target must be in + STATUS_GRAPH[source] for every source in source_statuses. + + Catches the case where an ActionSpec declares a transition the + state-machine graph doesn't actually support — e.g. action says + `pending → cancelled` but STATUS_GRAPH[pending] doesn't include + cancelled. Without this, a misconfigured ActionSpec would silently + fail at runtime when TaskService.transition() rejects the move. + """ + from roboco.foundation.policy.lifecycle import _ATOMIC_ACTIONS, STATUS_GRAPH + + for action_name, spec_action in _ATOMIC_ACTIONS.items(): + if spec_action.target_status is None: + continue + for source in spec_action.source_statuses: + if spec_action.target_status not in STATUS_GRAPH.get(source, frozenset()): + raise LifecycleSpecError( + f"Action '{action_name}': transition" + f" {source.value}→{spec_action.target_status.value}" + f" not in STATUS_GRAPH" + ) + + +def _check_role_team_rules_team_match() -> None: + """For each slug in ROLE_TEAM_RULES with a non-None team, that team + must match the seed agent record's team. None entries (cross-cell + roles like main-pm, board members, auditor, CEO) are intentionally + exempt — None means 'skip team-match enforcement for this slug', + not 'this slug has no team in the org chart'. The two tables encode + different concepts (enforcement vs descriptive); they only need to + agree on the cell-bound rows. + """ + from roboco.foundation.policy.lifecycle import ROLE_TEAM_RULES + from roboco.seeds.initial_data import DEFAULT_AGENTS + + seed_team: dict[str, str | None] = {} + for agent in DEFAULT_AGENTS: + slug = agent.get("slug") + if slug is None: + continue + seed_team[slug] = agent.get("team") + for slug, declared_team in ROLE_TEAM_RULES.items(): + if declared_team is None: + continue # cross-cell exemption — no agreement required + seed = seed_team.get(slug) + if seed != declared_team: + raise LifecycleSpecError( + f"ROLE_TEAM_RULES[{slug!r}]={declared_team!r} disagrees" + f" with seed team={seed!r}" + ) + + +def _check_unmigrated_is_subset() -> None: + """UNMIGRATED must be a strict subset of _KNOWN_UNMIGRATED_CONSUMERS. + + New entries not in the known set fail import — prevents silently + extending the known-debt set without documentation. + """ + from roboco.foundation.policy.lifecycle import ( + _KNOWN_UNMIGRATED_CONSUMERS, + UNMIGRATED, + ) + + extras = UNMIGRATED - _KNOWN_UNMIGRATED_CONSUMERS + if extras: + raise LifecycleSpecError( + f"UNMIGRATED contains unknown entries: {sorted(extras)}" + ) + + +_LIFECYCLE_VALIDATORS = ( + _check_status_enum_coverage, + _check_status_reachability, + _check_terminal_exits, + _check_intent_compositions, + _check_intent_chains, + _check_claim_rules_role_coverage, + _check_claim_rules_status_coverage, + _check_self_review_symmetry, + _check_role_team_rules_slugs, + _check_role_team_rules_team_match, + _check_status_transitions_actions, + _check_action_target_reachable_from_source, + _check_unmigrated_is_subset, +) + + +def run_all_lifecycle_validators() -> None: + """Run every lifecycle validator. First failure raises; the rest are skipped. + + Called from ``roboco.foundation.policy.lifecycle`` at module-load + time so the spec is validated at import. + """ + for validator in _LIFECYCLE_VALIDATORS: + validator() diff --git a/roboco/foundation/identity.py b/roboco/foundation/identity.py new file mode 100644 index 00000000..2138eed6 --- /dev/null +++ b/roboco/foundation/identity.py @@ -0,0 +1,236 @@ +"""Identity foundation — single source for roles, teams, agents, role-sets. + +Replaces the parallel definitions across: + - models/base.py (AgentRole, Team) + - lifecycle/spec.py (Role) + - agents_config.py (AGENT_ROLE_MAP, AGENT_TEAM_MAP, CELL_MEMBERS, PM_ROLES, + _BOARD_ROLES) + - seeds/initial_data.py (AGENT_UUIDS, DEFAULT_AGENTS slug+role+team fields) + - services/permissions.py (PM_ROLES — 2-role variant) + - runtime/orchestrator.py (_AGENT_TEAM_MAP + cell-prefix table) + +Every consumer imports from here. Adding an agent edits exactly this file. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum, StrEnum +from uuid import UUID + + +class Role(StrEnum): + DEVELOPER = "developer" + QA = "qa" + DOCUMENTER = "documenter" + CELL_PM = "cell_pm" + MAIN_PM = "main_pm" + PRODUCT_OWNER = "product_owner" + HEAD_MARKETING = "head_marketing" + AUDITOR = "auditor" + CEO = "ceo" + SYSTEM = "system" # sentinel only — used for orchestrator-generated rows + + +class Team(StrEnum): + BACKEND = "backend" + FRONTEND = "frontend" + UX_UI = "ux_ui" + BOARD = "board" + MAIN_PM = "main_pm" + FULLSTACK = "fullstack" + MARKETING = "marketing" # legacy — kept for seed-data parity, no agent declares it + SYSTEM = "system" + + +class RoleLevel(IntEnum): + SYSTEM = -1 + DEV = 1 + QA = 2 + DOCUMENTER = 3 + CELL_PM = 4 + MAIN_PM = 5 + BOARD = 6 + AUDITOR = 7 # observer override — sees everything + CEO = 8 + + +@dataclass(frozen=True) +class AgentRow: + """Identity record for one agent. Single source of truth.""" + + slug: str + role: Role + team: Team + uuid: UUID + is_human: bool = False # True only for ceo + + +def _u(s: str) -> UUID: + """Shorthand for UUID literals in the AGENTS table.""" + return UUID(s) + + +AGENTS: dict[str, AgentRow] = { + # System sentinel — used as from_agent for orchestrator-generated rows. + "system": AgentRow( + "system", Role.SYSTEM, Team.SYSTEM, _u("00000000-0000-0000-0000-000000000000") + ), + # CEO (Human) + "ceo": AgentRow( + "ceo", + Role.CEO, + Team.BOARD, + _u("00000000-0000-0000-0000-000000000001"), + is_human=True, + ), + # Backend cell + "be-dev-1": AgentRow( + "be-dev-1", + Role.DEVELOPER, + Team.BACKEND, + _u("00000000-0000-0000-0001-000000000001"), + ), + "be-dev-2": AgentRow( + "be-dev-2", + Role.DEVELOPER, + Team.BACKEND, + _u("00000000-0000-0000-0001-000000000002"), + ), + "be-qa": AgentRow( + "be-qa", Role.QA, Team.BACKEND, _u("00000000-0000-0000-0001-000000000003") + ), + "be-pm": AgentRow( + "be-pm", Role.CELL_PM, Team.BACKEND, _u("00000000-0000-0000-0001-000000000004") + ), + "be-doc": AgentRow( + "be-doc", + Role.DOCUMENTER, + Team.BACKEND, + _u("00000000-0000-0000-0001-000000000005"), + ), + # Frontend cell + "fe-dev-1": AgentRow( + "fe-dev-1", + Role.DEVELOPER, + Team.FRONTEND, + _u("00000000-0000-0000-0002-000000000001"), + ), + "fe-dev-2": AgentRow( + "fe-dev-2", + Role.DEVELOPER, + Team.FRONTEND, + _u("00000000-0000-0000-0002-000000000002"), + ), + "fe-qa": AgentRow( + "fe-qa", Role.QA, Team.FRONTEND, _u("00000000-0000-0000-0002-000000000003") + ), + "fe-pm": AgentRow( + "fe-pm", Role.CELL_PM, Team.FRONTEND, _u("00000000-0000-0000-0002-000000000004") + ), + "fe-doc": AgentRow( + "fe-doc", + Role.DOCUMENTER, + Team.FRONTEND, + _u("00000000-0000-0000-0002-000000000005"), + ), + # UX/UI cell + "ux-dev-1": AgentRow( + "ux-dev-1", + Role.DEVELOPER, + Team.UX_UI, + _u("00000000-0000-0000-0003-000000000001"), + ), + "ux-dev-2": AgentRow( + "ux-dev-2", + Role.DEVELOPER, + Team.UX_UI, + _u("00000000-0000-0000-0003-000000000002"), + ), + "ux-qa": AgentRow( + "ux-qa", Role.QA, Team.UX_UI, _u("00000000-0000-0000-0003-000000000003") + ), + "ux-pm": AgentRow( + "ux-pm", Role.CELL_PM, Team.UX_UI, _u("00000000-0000-0000-0003-000000000004") + ), + "ux-doc": AgentRow( + "ux-doc", + Role.DOCUMENTER, + Team.UX_UI, + _u("00000000-0000-0000-0003-000000000005"), + ), + # Board / Management + "main-pm": AgentRow( + "main-pm", + Role.MAIN_PM, + Team.MAIN_PM, + _u("00000000-0000-0000-0004-000000000001"), + ), + "product-owner": AgentRow( + "product-owner", + Role.PRODUCT_OWNER, + Team.BOARD, + _u("00000000-0000-0000-0004-000000000002"), + ), + "head-marketing": AgentRow( + "head-marketing", + Role.HEAD_MARKETING, + Team.BOARD, + _u("00000000-0000-0000-0004-000000000003"), + ), + "auditor": AgentRow( + "auditor", Role.AUDITOR, Team.BOARD, _u("00000000-0000-0000-0004-000000000004") + ), +} + + +# Role-sets (frozensets so they're hashable + immutable) +PM_ROLES: frozenset[Role] = frozenset({Role.CELL_PM, Role.MAIN_PM}) +BOARD_ROLES: frozenset[Role] = frozenset( + {Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.AUDITOR} +) +DEV_ROLES: frozenset[Role] = frozenset({Role.DEVELOPER}) +ALL_ROLES: frozenset[Role] = frozenset(Role) + +# Hierarchical level for "X or above" checks. SYSTEM is the sentinel below all +# real roles. Auditor sits above main_pm because the auditor can read +# everywhere; CEO is the absolute ceiling. +ROLE_LEVEL: dict[Role, RoleLevel] = { + Role.SYSTEM: RoleLevel.SYSTEM, + Role.DEVELOPER: RoleLevel.DEV, + Role.QA: RoleLevel.QA, + Role.DOCUMENTER: RoleLevel.DOCUMENTER, + Role.CELL_PM: RoleLevel.CELL_PM, + Role.MAIN_PM: RoleLevel.MAIN_PM, + Role.PRODUCT_OWNER: RoleLevel.BOARD, + Role.HEAD_MARKETING: RoleLevel.BOARD, + Role.AUDITOR: RoleLevel.AUDITOR, + Role.CEO: RoleLevel.CEO, +} + + +def agent_for_slug(slug: str) -> AgentRow: + """Return the AgentRow for a slug; raises KeyError on unknown.""" + if slug not in AGENTS: + raise KeyError(f"unknown agent slug: {slug!r} (known: {sorted(AGENTS)})") + return AGENTS[slug] + + +def slugs_for_role(role: Role) -> frozenset[str]: + """Return the frozenset of slugs whose agent has this role.""" + return frozenset(slug for slug, row in AGENTS.items() if row.role == role) + + +def slugs_for_team(team: Team) -> frozenset[str]: + """Return the frozenset of slugs whose agent is on this team.""" + return frozenset(slug for slug, row in AGENTS.items() if row.team == team) + + +def role_for_slug(slug: str) -> Role: + """Shorthand for `agent_for_slug(slug).role`.""" + return agent_for_slug(slug).role + + +def team_for_slug(slug: str) -> Team: + """Shorthand for `agent_for_slug(slug).team`.""" + return agent_for_slug(slug).team diff --git a/roboco/foundation/policy/__init__.py b/roboco/foundation/policy/__init__.py new file mode 100644 index 00000000..c8216bcc --- /dev/null +++ b/roboco/foundation/policy/__init__.py @@ -0,0 +1 @@ +"""Per-domain policy modules. Each module owns one cross-cutting rule set.""" diff --git a/roboco/foundation/policy/agent_loop.py b/roboco/foundation/policy/agent_loop.py new file mode 100644 index 00000000..2497ec49 --- /dev/null +++ b/roboco/foundation/policy/agent_loop.py @@ -0,0 +1,96 @@ +"""Agent-loop foundation. + +Owns budget thresholds, loop-detection action, and per-verb circuit breakers. + +Replaces (subsequent tasks migrate consumers): + - agent_sdk/server.py: 527-534 (hand-coded constants for warn/halt/loop thresholds) + - runtime/orchestrator.py: 3807 (_PM_RESPAWN_MAX_UNPRODUCTIVE) + - docker/scripts/post-tool-budget-hook.sh exit-0-on-loop (Task 13 changes to exit 1) + +The verb-level circuit breaker (VERB_RETRY_LIMITS) is NEW. Pre-Phase-3 the +gateway had no per-verb retry cap — the 2026-05-10 smoke run showed +i_am_done retried 5+ times in 2 minutes within the global budget. After +Task 14 lands the runtime tracker, exceeding VERB_RETRY_LIMITS[verb] +attempts in 60s returns Envelope.circuit_open. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class BudgetPolicy: + """Per-agent runtime budget + loop policy. + + All thresholds are env-overridable at the consumer layer (agent_sdk + reads ROBOCO_AGENT_TOOL_CALL_WARN etc. and falls back to these + defaults). The dataclass holds the canonical defaults. + """ + + tool_call_warn_at: int = 50 + tool_call_halt_at: int = 150 + loop_threshold: int = 3 # same tool+args repeats to trigger + loop_window: int = 10 # rolling-window size + loop_action: Literal["warn", "halt"] = "halt" # NEW: was effectively "warn" + pm_respawn_max_unproductive: int = 3 + verb_retry_max_per_minute: int = 3 # default cap for verbs not in VERB_RETRY_LIMITS + + +DEFAULT_BUDGET: BudgetPolicy = BudgetPolicy() + + +# Per-verb retry caps. Verbs that hit a tracing_gap or invalid_state and +# get retried more than this many times in a 60s window will receive a +# circuit_open envelope on the next attempt. +VERB_RETRY_LIMITS: dict[str, int] = { + # Handoff verbs the 2026-05-10 smoke run showed retry-storming: + "i_am_done": 3, + "submit_up": 3, + "complete": 3, + "delegate": 3, + # QA / Doc handoffs: + "pass_review": 3, + "fail_review": 3, + "i_documented": 3, + # PR open is more network-flake-tolerant: + "open_pr": 5, + # Block / escalation paths: + "i_am_blocked": 3, + "escalate_up": 3, + "escalate_to_ceo": 3, + "unblock": 3, +} + + +# Verbs that are NOT subject to the per-verb circuit breaker. Read-only or +# discovery operations the agent uses to figure out what's going on. +UNLIMITED_RETRY_VERBS: frozenset[str] = frozenset( + { + "give_me_work", + "triage", + "triage_all", + "evidence", + "i_am_idle", + "unclaim", + "resume", + "claim_review", + "claim_doc_task", + "i_will_work_on", + "i_will_plan", # claim verbs — agent may retry on transient lock contention + } +) + + +def retry_limit_for(verb: str) -> int | None: + """Return the per-verb retry cap, or None for unlimited. + + Lookup order: + 1. VERB_RETRY_LIMITS[verb] — explicit per-verb cap + 2. UNLIMITED_RETRY_VERBS — None (no cap) + 3. DEFAULT_BUDGET.verb_retry_max_per_minute — fallback for unknown verbs + """ + if verb in UNLIMITED_RETRY_VERBS: + return None + return VERB_RETRY_LIMITS.get(verb, DEFAULT_BUDGET.verb_retry_max_per_minute) diff --git a/roboco/foundation/policy/communications.py b/roboco/foundation/policy/communications.py new file mode 100644 index 00000000..bc9db33a --- /dev/null +++ b/roboco/foundation/policy/communications.py @@ -0,0 +1,278 @@ +"""Communications foundation — channel topology + A2A urgency + notification rules. + +Single source of truth for: + - Notification sender allowlist (replaces _NOTIFY_ALLOWED_ROLES + + NOTIFICATION_PERMISSIONS) + - NotificationType -> requires_ack mapping (replaces 7 hand-set callsites in + services/notification_delivery.py) + - Priority enum (re-exports models.base.NotificationPriority for SQLAlchemy compat) + - CHANNELS catalog: channel topology (slug -> role-keyed read/write/silent) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from roboco.foundation.identity import Role, Team +from roboco.models.base import ChannelType, NotificationPriority, NotificationType + +# Re-export the enum so consumers can import a single name. +Priority = NotificationPriority + + +def parse_priority( + raw_priority: object | None, + legacy_urgent_flag: bool = False, +) -> NotificationPriority: + """Resolve a Priority value from mixed-source A2A inputs. + + Precedence (P3 Task 9 — A2A urgency tristate): + 1. ``raw_priority`` — string matching the Priority enum + ("normal" | "high" | "urgent"). Unknown values fall back to NORMAL. + 2. ``legacy_urgent_flag`` — legacy bool from + ``SendMessageConfiguration.urgent`` or ``metadata['urgent']``; + True maps to URGENT. + 3. Default: NORMAL. + + Centralizing here keeps the tristate single-sourced and the call site + branch-light. + """ + if raw_priority is not None: + try: + return Priority(str(raw_priority)) + except ValueError: + return Priority.NORMAL + if legacy_urgent_flag: + return Priority.URGENT + return Priority.NORMAL + + +# Roles permitted to call notify() (PM/Board/CEO; auditor is silent observer). +NOTIFY_SENDER_ROLES: frozenset[Role] = frozenset( + { + Role.CELL_PM, + Role.MAIN_PM, + Role.PRODUCT_OWNER, + Role.HEAD_MARKETING, + Role.CEO, + } +) + + +# NotificationType -> requires_ack mapping. +# Convention from spec §5.5: +# - Action-required (CEO/PM acks needed) -> True +# - Informational (no ack) -> False +ACK_REQUIRED_BY_TYPE: dict[NotificationType, bool] = { + # Action-required: recipient must explicitly ack. + NotificationType.PRIORITY_CHANGE: True, # priority shift demands acknowledgment + NotificationType.BLOCKER_ESCALATION: True, # escalations need confirmation + NotificationType.APPROVAL: True, # approval requests must be answered + NotificationType.ALERT: True, # alerts demand attention + # Informational: no ack required. + NotificationType.TASK_ASSIGNMENT: False, # claim flow proves receipt + NotificationType.REVIEW_REQUEST: False, # QA pickup proves receipt + NotificationType.DOCUMENTATION_REQUEST: False, # doc pickup proves receipt + NotificationType.BROADCAST: False, # one-to-many announcement + NotificationType.KNOWLEDGE_SHARE: False, # cross-agent learning, no ack + NotificationType.MENTION: False, # chat @mention, no ack + NotificationType.A2A_REQUEST: False, # request/reply lives at message layer +} + + +# ============================================================================= +# CHANNELS catalog +# ============================================================================= +# +# The single source of truth for channel topology. Each entry binds a slug +# (the durable channel identifier) to the roles permitted to read/write, +# the roles that read silently (no write), the channel display type, and +# whether the channel is read-only for roles outside `write_roles` (the +# spec §5.5 "announcements" pattern). +# +# This catalog is the canonical replacement for the legacy data in +# `roboco.agents_config.CHANNEL_ACCESS` (agent-id keyed) and the display +# metadata in `roboco.seeds.initial_data.DEFAULT_CHANNELS`. Subsequent +# foundation tasks derive both from this dict. + + +@dataclass(frozen=True) +class ChannelSpec: + slug: str + description: str + type: ChannelType + read_roles: frozenset[Role] + write_roles: frozenset[Role] + silent_roles: frozenset[Role] = field(default_factory=frozenset) + read_only_for_others: bool = False + # When set, cell-member roles (DEVELOPER/QA/DOCUMENTER/CELL_PM) are + # constrained to agents on this team. Cross-cell roles (MAIN_PM, AUDITOR, + # CEO, board) are NOT filtered — they participate regardless of team. + # Required for cell channels (backend-cell etc.) to derive correct slug + # membership without leaking other cells' members. + team_scope: Team | None = None + + +# -- Helper sets (DRY across multiple channels) ------------------------------- + +# Roles present in every cell channel: cell members + main-pm. +_CELL_READ: frozenset[Role] = frozenset( + { + Role.DEVELOPER, + Role.QA, + Role.DOCUMENTER, + Role.CELL_PM, + Role.MAIN_PM, + } +) +_CELL_WRITE: frozenset[Role] = _CELL_READ + +# Roles present in cross-cell role channels (dev-all/qa-all read-side): +# every cell member role plus the PM coordination layer. +_CROSS_CELL_READ_ALL: frozenset[Role] = frozenset( + { + Role.DEVELOPER, + Role.QA, + Role.DOCUMENTER, + Role.CELL_PM, + Role.MAIN_PM, + } +) + +# Auditor is the silent observer on every cell + cross-cell channel. +_AUDITOR_ONLY: frozenset[Role] = frozenset({Role.AUDITOR}) + +# All non-system roles (legacy ALL_AGENTS in role-terms): every Role except +# the SYSTEM sentinel. Used for company-wide channels (announcements, +# all-hands). +_ALL_ROLES: frozenset[Role] = frozenset(r for r in Role if r is not Role.SYSTEM) + + +CHANNELS: dict[str, ChannelSpec] = { + # -- Cell channels (members + main-pm read/write, auditor silent) -------- + "backend-cell": ChannelSpec( + slug="backend-cell", + description="Backend development team channel", + type=ChannelType.CELL, + read_roles=_CELL_READ | _AUDITOR_ONLY, + write_roles=_CELL_WRITE, + silent_roles=_AUDITOR_ONLY, + team_scope=Team.BACKEND, + ), + "frontend-cell": ChannelSpec( + slug="frontend-cell", + description="Frontend development team channel", + type=ChannelType.CELL, + read_roles=_CELL_READ | _AUDITOR_ONLY, + write_roles=_CELL_WRITE, + silent_roles=_AUDITOR_ONLY, + team_scope=Team.FRONTEND, + ), + "uxui-cell": ChannelSpec( + slug="uxui-cell", + description="UX/UI design team channel", + type=ChannelType.CELL, + read_roles=_CELL_READ | _AUDITOR_ONLY, + write_roles=_CELL_WRITE, + silent_roles=_AUDITOR_ONLY, + team_scope=Team.UX_UI, + ), + # -- Cross-cell role channels -------------------------------------------- + # dev-all: all cell-member roles read; only DEVELOPER + CELL_PM + MAIN_PM + # write. Auditor silent. + "dev-all": ChannelSpec( + slug="dev-all", + description="Cross-cell developer discussion", + type=ChannelType.CROSS_CELL, + read_roles=_CROSS_CELL_READ_ALL | _AUDITOR_ONLY, + write_roles=frozenset({Role.DEVELOPER, Role.CELL_PM, Role.MAIN_PM}), + silent_roles=_AUDITOR_ONLY, + ), + # qa-all: same read fan-out as dev-all; QA + CELL_PM write (no main-pm + # write per legacy CHANNEL_ACCESS). + "qa-all": ChannelSpec( + slug="qa-all", + description="Cross-cell QA discussion", + type=ChannelType.CROSS_CELL, + read_roles=_CROSS_CELL_READ_ALL | _AUDITOR_ONLY, + write_roles=frozenset({Role.QA, Role.CELL_PM}), + silent_roles=_AUDITOR_ONLY, + ), + # pm-all: PM-only coordination — cell PMs + main-pm. + "pm-all": ChannelSpec( + slug="pm-all", + description="Cross-cell PM coordination", + type=ChannelType.CROSS_CELL, + read_roles=frozenset({Role.CELL_PM, Role.MAIN_PM}) | _AUDITOR_ONLY, + write_roles=frozenset({Role.CELL_PM, Role.MAIN_PM}), + silent_roles=_AUDITOR_ONLY, + ), + # doc-all: documenters + cell PMs + main-pm read; documenters + cell PMs + # write. + "doc-all": ChannelSpec( + slug="doc-all", + description="Cross-cell documentation discussion", + type=ChannelType.CROSS_CELL, + read_roles=frozenset({Role.DOCUMENTER, Role.CELL_PM, Role.MAIN_PM}) + | _AUDITOR_ONLY, + write_roles=frozenset({Role.DOCUMENTER, Role.CELL_PM}), + silent_roles=_AUDITOR_ONLY, + ), + # -- Management channels -------------------------------------------------- + # Legacy CHANNEL_ACCESS lists auditor as both read AND write here. We + # preserve that behaviour for parity; the runtime guard that downgrades + # auditor to silent lives in services (Phase 3 Task 8). + "main-pm-board": ChannelSpec( + slug="main-pm-board", + description="Main PM and Board communication", + type=ChannelType.MANAGEMENT, + read_roles=frozenset( + {Role.MAIN_PM, Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.AUDITOR} + ), + write_roles=frozenset( + {Role.MAIN_PM, Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.AUDITOR} + ), + silent_roles=frozenset(), + ), + "board-private": ChannelSpec( + slug="board-private", + description="Board-only discussions", + type=ChannelType.MANAGEMENT, + read_roles=frozenset( + { + Role.PRODUCT_OWNER, + Role.HEAD_MARKETING, + Role.AUDITOR, + Role.CEO, + Role.MAIN_PM, + } + ), + write_roles=frozenset( + {Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.AUDITOR, Role.CEO} + ), + silent_roles=frozenset(), + ), + # -- Special / broadcast channels ----------------------------------------- + # announcements: company-wide read; only main-pm/board/ceo write. + # Spec §5.5 marks this as the canonical read-only channel. + "announcements": ChannelSpec( + slug="announcements", + description="Company-wide announcements (read-only for most)", + type=ChannelType.SPECIAL, + read_roles=_ALL_ROLES, + write_roles=frozenset( + {Role.MAIN_PM, Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.CEO} + ), + silent_roles=frozenset(), + read_only_for_others=True, + ), + # all-hands: company-wide open discussion — every role reads and writes. + "all-hands": ChannelSpec( + slug="all-hands", + description="Company-wide open discussion", + type=ChannelType.SPECIAL, + read_roles=_ALL_ROLES, + write_roles=_ALL_ROLES, + silent_roles=frozenset(), + ), +} diff --git a/roboco/foundation/policy/journaling.py b/roboco/foundation/policy/journaling.py new file mode 100644 index 00000000..dc0694ec --- /dev/null +++ b/roboco/foundation/policy/journaling.py @@ -0,0 +1,67 @@ +"""Journaling foundation — scope catalog + scope→type mapping. + +The 5 scopes the panel UI exposes (Notes/Decisions/Reflections/Learnings/ +Struggles) are first-class. Each scope must be wired to at least one verb's +required-set in foundation.policy.tracing.VERB_REQUIREMENTS — that wiring +keeps the scope catalog from drifting into "documented but unused". + +Replaces: + - services/gateway/content_actions._VALID_NOTE_SCOPES (frozenset of strings) + - services/journal._SCOPE_TO_TYPE (scope-string → JournalEntryType) +""" + +from __future__ import annotations + +from enum import StrEnum + +from roboco.foundation.identity import Role +from roboco.models.base import JournalEntryType + + +class Scope(StrEnum): + """Journal entry scope — agent-facing string values.""" + + NOTE = "note" # Quick observation, not load-bearing + DECISION = "decision" # Reasoning before an action; PM scope rationale + REFLECT = "reflect" # End-of-task / end-of-merge retrospection + LEARNING = "learning" # Pattern worth surfacing to the team + STRUGGLE = "struggle" # Stuck point, before / instead of i_am_blocked + + +# Scope → SQLAlchemy entry type. Single source for the mapping. +SCOPE_TO_TYPE: dict[Scope, JournalEntryType] = { + Scope.NOTE: JournalEntryType.GENERAL, + Scope.DECISION: JournalEntryType.DECISION_LOG, + Scope.REFLECT: JournalEntryType.TASK_REFLECTION, + Scope.LEARNING: JournalEntryType.LEARNING, + Scope.STRUGGLE: JournalEntryType.STRUGGLE, +} + + +class ReadTier(StrEnum): + """How widely a role can read other agents' journals.""" + + OWN = "own" # only my own + CELL = "cell" # my cell only + CELL_AND_PMS = "cell_and_pms" # my cell + PM chain + ALL_CELLS = "all_cells" # every cell (for cross-cell roles) + ALL = "all" # every journal (auditor / CEO) + + +ROLE_READ_TIERS: dict[Role, ReadTier] = { + Role.SYSTEM: ReadTier.OWN, + Role.DEVELOPER: ReadTier.CELL, + Role.QA: ReadTier.CELL, + Role.DOCUMENTER: ReadTier.CELL, + Role.CELL_PM: ReadTier.CELL_AND_PMS, + Role.MAIN_PM: ReadTier.ALL_CELLS, + Role.PRODUCT_OWNER: ReadTier.ALL_CELLS, + Role.HEAD_MARKETING: ReadTier.ALL_CELLS, + Role.AUDITOR: ReadTier.ALL, + Role.CEO: ReadTier.ALL, +} + + +# Slugs whose journals are "protected" — only the agent themselves can read. +# Pre-gateway: enforcement/journal_perms.PROTECTED_JOURNALS. +PROTECTED_JOURNALS: frozenset[str] = frozenset({"ceo", "auditor"}) diff --git a/roboco/foundation/policy/lifecycle.py b/roboco/foundation/policy/lifecycle.py new file mode 100644 index 00000000..afa9b20a --- /dev/null +++ b/roboco/foundation/policy/lifecycle.py @@ -0,0 +1,1336 @@ +"""Canonical lifecycle + permissions spec. + +Single source of truth for: + - task lifecycle status transitions + - per-role permissions on atomic actions + - per-role permissions on gateway intent verbs + - claim restrictions + - team-based access rules + - self-review prevention rules + +Every consumer (choreographer, MCP manifest, RAG corpus, agent prompts, +panel UI, tests, middleware) reads its behavior from this module. + +Predecessor canon (prose): + - docs/internal/old/workflows/STATUS_TRANSITIONS.md + - docs/internal/old/workflows/PERMISSIONS.md + +If this module disagrees with those documents, the discrepancy is +recorded in the spec design doc: + docs/superpowers/specs/2026-05-09-lifecycle-canonical-spec-design.md +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import TYPE_CHECKING, Any, Literal + +# Role enum is canonicalized in roboco/foundation/identity.py. +# Re-exported here so callers can import `Role` from this module alongside +# the lifecycle tables that depend on it. New consumers may also import +# from `roboco.foundation.identity` directly. +from roboco.foundation.identity import Role + +if TYPE_CHECKING: + from collections.abc import Callable + from uuid import UUID + + +class Status(StrEnum): + BACKLOG = "backlog" + PENDING = "pending" + CLAIMED = "claimed" + IN_PROGRESS = "in_progress" + BLOCKED = "blocked" + PAUSED = "paused" + VERIFYING = "verifying" + AWAITING_QA = "awaiting_qa" + NEEDS_REVISION = "needs_revision" + AWAITING_DOCUMENTATION = "awaiting_documentation" + AWAITING_PM_REVIEW = "awaiting_pm_review" + AWAITING_CEO_APPROVAL = "awaiting_ceo_approval" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class TaskType(StrEnum): + CODE = "code" + DOCUMENTATION = "documentation" + RESEARCH = "research" + PLANNING = "planning" + DESIGN = "design" + ADMINISTRATIVE = "administrative" + + +RejectionKind = Literal[ + "not_authorized", + "invalid_state", + "tracing_gap", + "self_review", + "not_found", +] + + +@dataclass(frozen=True) +class Decision: + """Single shape every consumer maps onto its native rejection format. + + `allow()`, `reject(kind, ...)`, and `tracing_gap(missing, remediate)` + are the three canonical constructors. Direct __init__ is supported + but enforces the invariants below so callers can't build a malformed + Decision. + + Invariants (enforced in __post_init__): + * allowed=True ⇒ rejection_kind is None and missing == [] + * allowed=False ⇒ rejection_kind is not None + """ + + allowed: bool + rejection_kind: RejectionKind | None + message: str | None + missing: list[str] = field(default_factory=list) + remediate: str | None = None + + def __post_init__(self) -> None: + if self.allowed and self.rejection_kind is not None: + raise ValueError( + "Decision invariant: allowed=True requires rejection_kind=None" + ) + if not self.allowed and self.rejection_kind is None: + raise ValueError( + "Decision invariant: allowed=False requires rejection_kind set" + ) + if self.allowed and (self.missing or self.remediate is not None): + raise ValueError("allowed=True requires missing=[] and remediate=None") + + @classmethod + def allow(cls) -> Decision: + return cls( + allowed=True, + rejection_kind=None, + message=None, + missing=[], + remediate=None, + ) + + @classmethod + def reject( + cls, + *, + kind: RejectionKind, + message: str, + remediate: str, + ) -> Decision: + return cls( + allowed=False, + rejection_kind=kind, + message=message, + missing=[], + remediate=remediate, + ) + + @classmethod + def tracing_gap(cls, *, missing: list[str], remediate: str) -> Decision: + return cls( + allowed=False, + rejection_kind="tracing_gap", + message=None, + missing=list(missing), + remediate=remediate, + ) + + +@dataclass(frozen=True) +class Precondition: + """Declarative gate-table row. + + `check` returns True if the precondition holds. `remediate` is the + human-readable hint surfaced verbatim on rejection. `missing_token` + is what shows up in the `tracing_gap.missing[]` field of the + envelope (so agents can do exact-string checks). + """ + + key: str + check: Callable[[Any, Any, Any], bool] + remediate: str + missing_token: str + + +@dataclass(frozen=True) +class ActionSpec: + """Atomic, pre-gateway-style action (claim, start, submit_qa, ...). + + `target_status=None` means the action does not transition the task + (e.g. progress-recording actions). `allowed_task_types=None` means + no restriction. `needs_team_match` is the agent.team == task.team + rule from PERMISSIONS.md (Team-Based Restrictions). + """ + + name: str + allowed_roles: frozenset[Role] + source_statuses: frozenset[Status] + target_status: Status | None + allowed_task_types: frozenset[TaskType] | None + preconditions: tuple[Precondition, ...] + self_review_block: bool + needs_team_match: bool + + +@dataclass(frozen=True) +class IntentSpec: + """Gateway intent verb — a named, atomic composition of ActionSpecs. + + `composes` lists the atomic action names in the order they execute. + `extra_preconditions` are verb-level checks the composing actions + don't cover (e.g. open_pr's "no PR already open" check). + `side_effects` is a tuple of named git/branch/PR operations the + runner invokes after the DB savepoint commits. + """ + + name: str + allowed_roles: frozenset[Role] + description: str + composes: tuple[str, ...] + extra_preconditions: tuple[Precondition, ...] + side_effects: tuple[str, ...] + next_hint: Callable[[Any], str] + + +@dataclass(frozen=True) +class StatusTransition: + """A row from STATUS_TRANSITIONS.md, machine-readable. + + `role_constraint=None` means: inherit whatever the + `triggered_by_action`'s ActionSpec.allowed_roles says. Set explicitly + only when the transition's role gate differs from the action's. + """ + + source: Status + target: Status + triggered_by_action: str + role_constraint: frozenset[Role] | None + + +# --------------------------------------------------------------------------- +# Status transitions (predecessor canon: STATUS_TRANSITIONS.md) +# --------------------------------------------------------------------------- + +_STATUS_TRANSITIONS: tuple[StatusTransition, ...] = ( + # PM setup + StatusTransition(Status.BACKLOG, Status.PENDING, "activate", None), + # Claim path. role_constraint=None on rows below means "any role — + # the per-role-vs-status filtering is in CLAIM_RULES (Task 5)". + # A None here is NOT an oversight; it is the explicit handoff + # point between the StatusTransition table (state machine) and + # CLAIM_RULES (per-role claim authority). + StatusTransition(Status.PENDING, Status.CLAIMED, "claim", None), + StatusTransition(Status.AWAITING_QA, Status.CLAIMED, "claim", frozenset({Role.QA})), + StatusTransition( + Status.AWAITING_DOCUMENTATION, + Status.CLAIMED, + "claim", + frozenset({Role.DOCUMENTER}), + ), + StatusTransition(Status.NEEDS_REVISION, Status.CLAIMED, "claim", None), + # Start + StatusTransition(Status.CLAIMED, Status.IN_PROGRESS, "start", None), + # Block / pause / resume + StatusTransition(Status.IN_PROGRESS, Status.BLOCKED, "block", None), + StatusTransition(Status.IN_PROGRESS, Status.PAUSED, "pause", None), + StatusTransition(Status.BLOCKED, Status.IN_PROGRESS, "unblock", None), + StatusTransition(Status.PAUSED, Status.IN_PROGRESS, "resume", None), + # Dev verify + submit + StatusTransition(Status.IN_PROGRESS, Status.VERIFYING, "submit_verification", None), + StatusTransition(Status.VERIFYING, Status.AWAITING_QA, "submit_qa", None), + # QA pass / fail + StatusTransition( + Status.AWAITING_QA, + Status.AWAITING_DOCUMENTATION, + "qa_pass", + frozenset({Role.QA}), + ), + StatusTransition( + Status.AWAITING_QA, + Status.NEEDS_REVISION, + "qa_fail", + frozenset({Role.QA}), + ), + # Documenter completes + StatusTransition( + Status.AWAITING_DOCUMENTATION, + Status.AWAITING_PM_REVIEW, + "docs_complete", + frozenset({Role.DOCUMENTER}), + ), + # PM completes / escalates + StatusTransition( + Status.AWAITING_PM_REVIEW, + Status.COMPLETED, + "complete", + frozenset({Role.CELL_PM, Role.MAIN_PM}), + ), + StatusTransition( + Status.AWAITING_PM_REVIEW, + Status.AWAITING_CEO_APPROVAL, + "escalate_to_ceo", + frozenset({Role.MAIN_PM, Role.PRODUCT_OWNER, Role.HEAD_MARKETING}), + ), + # CEO approve / reject + StatusTransition( + Status.AWAITING_CEO_APPROVAL, + Status.COMPLETED, + "ceo_approve", + frozenset({Role.CEO}), + ), + StatusTransition( + Status.AWAITING_CEO_APPROVAL, + Status.NEEDS_REVISION, + "ceo_reject", + frozenset({Role.CEO}), + ), + # Direct PM submission for non-dev tasks + StatusTransition( + Status.IN_PROGRESS, + Status.AWAITING_PM_REVIEW, + "submit_pm_review", + None, + ), + # Cancel — PM/CEO can cancel from any non-terminal status + *( + StatusTransition( + src, + Status.CANCELLED, + "cancel", + frozenset({Role.CELL_PM, Role.MAIN_PM, Role.CEO}), + ) + for src in Status + if src not in (Status.COMPLETED, Status.CANCELLED) + ), +) + + +def _build_status_graph() -> dict[Status, frozenset[Status]]: + """`source → frozenset(targets)` view derived from _STATUS_TRANSITIONS.""" + graph: dict[Status, set[Status]] = {s: set() for s in Status} + for t in _STATUS_TRANSITIONS: + graph[t.source].add(t.target) + return {src: frozenset(targets) for src, targets in graph.items()} + + +STATUS_GRAPH: dict[Status, frozenset[Status]] = _build_status_graph() + + +# --------------------------------------------------------------------------- +# Atomic actions (predecessor canon: PERMISSIONS.md "Task Management Tools") +# --------------------------------------------------------------------------- + +_PM_ROLES: frozenset[Role] = frozenset({Role.CELL_PM, Role.MAIN_PM}) +_DEV_ROLES: frozenset[Role] = frozenset({Role.DEVELOPER}) +_QA_ROLES: frozenset[Role] = frozenset({Role.QA}) +_DOC_ROLES: frozenset[Role] = frozenset({Role.DOCUMENTER}) + + +_ATOMIC_ACTIONS: dict[str, ActionSpec] = { + "activate": ActionSpec( + name="activate", + allowed_roles=_PM_ROLES, + source_statuses=frozenset({Status.BACKLOG}), + target_status=Status.PENDING, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=False, + ), + # claim's source_statuses is the UNION across all roles — see CLAIM_RULES + # for per-role authority. Both tables are authoritative; Task 8 validates + # consistency between them. + "claim": ActionSpec( + name="claim", + allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + source_statuses=frozenset( + { + Status.PENDING, + Status.NEEDS_REVISION, + Status.AWAITING_QA, + Status.AWAITING_DOCUMENTATION, + } + ), + target_status=Status.CLAIMED, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "start": ActionSpec( + name="start", + allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + source_statuses=frozenset({Status.CLAIMED}), + target_status=Status.IN_PROGRESS, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "set_plan": ActionSpec( + name="set_plan", + allowed_roles=frozenset(_DEV_ROLES | _PM_ROLES), + source_statuses=frozenset({Status.CLAIMED}), + target_status=None, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "block": ActionSpec( + name="block", + allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + source_statuses=frozenset({Status.IN_PROGRESS}), + target_status=Status.BLOCKED, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "unblock": ActionSpec( + name="unblock", + allowed_roles=_PM_ROLES, + source_statuses=frozenset({Status.BLOCKED}), + target_status=Status.IN_PROGRESS, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=False, + ), + "pause": ActionSpec( + name="pause", + allowed_roles=frozenset(_DEV_ROLES | _PM_ROLES), + source_statuses=frozenset({Status.IN_PROGRESS}), + target_status=Status.PAUSED, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "resume": ActionSpec( + name="resume", + allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + source_statuses=frozenset({Status.PAUSED}), + target_status=Status.IN_PROGRESS, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=False, + ), + "submit_verification": ActionSpec( + name="submit_verification", + allowed_roles=_DEV_ROLES, + source_statuses=frozenset({Status.IN_PROGRESS}), + target_status=Status.VERIFYING, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "submit_qa": ActionSpec( + name="submit_qa", + allowed_roles=_DEV_ROLES, + source_statuses=frozenset({Status.VERIFYING}), + target_status=Status.AWAITING_QA, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "qa_pass": ActionSpec( + name="qa_pass", + allowed_roles=_QA_ROLES, + source_statuses=frozenset({Status.AWAITING_QA}), + target_status=Status.AWAITING_DOCUMENTATION, + allowed_task_types=None, + preconditions=(), + self_review_block=True, + needs_team_match=True, + ), + "qa_fail": ActionSpec( + name="qa_fail", + allowed_roles=_QA_ROLES, + source_statuses=frozenset({Status.AWAITING_QA}), + target_status=Status.NEEDS_REVISION, + allowed_task_types=None, + preconditions=(), + self_review_block=True, + needs_team_match=True, + ), + "docs_complete": ActionSpec( + name="docs_complete", + allowed_roles=_DOC_ROLES, + source_statuses=frozenset({Status.AWAITING_DOCUMENTATION}), + target_status=Status.AWAITING_PM_REVIEW, + allowed_task_types=None, + preconditions=(), + self_review_block=True, + needs_team_match=True, + ), + "complete": ActionSpec( + name="complete", + allowed_roles=_PM_ROLES, + source_statuses=frozenset({Status.AWAITING_PM_REVIEW}), + target_status=Status.COMPLETED, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "submit_pm_review": ActionSpec( + name="submit_pm_review", + allowed_roles=frozenset(_PM_ROLES | _QA_ROLES | _DOC_ROLES | _DEV_ROLES), + source_statuses=frozenset({Status.IN_PROGRESS}), + target_status=Status.AWAITING_PM_REVIEW, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), + "escalate_to_ceo": ActionSpec( + name="escalate_to_ceo", + allowed_roles=frozenset( + { + Role.MAIN_PM, + Role.PRODUCT_OWNER, + Role.HEAD_MARKETING, + } + ), + source_statuses=frozenset({Status.AWAITING_PM_REVIEW}), + target_status=Status.AWAITING_CEO_APPROVAL, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=False, + ), + "ceo_approve": ActionSpec( + name="ceo_approve", + allowed_roles=frozenset({Role.CEO}), + source_statuses=frozenset({Status.AWAITING_CEO_APPROVAL}), + target_status=Status.COMPLETED, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=False, + ), + "ceo_reject": ActionSpec( + name="ceo_reject", + allowed_roles=frozenset({Role.CEO}), + source_statuses=frozenset({Status.AWAITING_CEO_APPROVAL}), + target_status=Status.NEEDS_REVISION, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=False, + ), + "cancel": ActionSpec( + name="cancel", + allowed_roles=frozenset(_PM_ROLES | {Role.CEO}), + source_statuses=frozenset( + s for s in Status if s not in (Status.COMPLETED, Status.CANCELLED) + ), + target_status=Status.CANCELLED, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=False, + ), + "create_subtask": ActionSpec( + name="create_subtask", + allowed_roles=_PM_ROLES, + source_statuses=frozenset({Status.IN_PROGRESS}), # parent must be in_progress + target_status=None, # creates a NEW task; doesn't transition the parent + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ), +} + + +# --------------------------------------------------------------------------- +# Claim rules (predecessor canon: PERMISSIONS.md "Claim Restrictions by Role") +# --------------------------------------------------------------------------- + +CLAIM_RULES: dict[Role, frozenset[Status]] = { + Role.DEVELOPER: frozenset({Status.PENDING, Status.NEEDS_REVISION}), + Role.QA: frozenset({Status.AWAITING_QA}), + Role.DOCUMENTER: frozenset({Status.PENDING, Status.AWAITING_DOCUMENTATION}), + Role.CELL_PM: frozenset({Status.PENDING}), + Role.MAIN_PM: frozenset({Status.PENDING}), + Role.PRODUCT_OWNER: frozenset(), + Role.HEAD_MARKETING: frozenset(), + Role.AUDITOR: frozenset(), + Role.CEO: frozenset(), +} + + +# --------------------------------------------------------------------------- +# Team rules (predecessor canon: PERMISSIONS.md "Team-Based Restrictions") +# Per-slug. None means "any team" (cross-cell or board roles). +# --------------------------------------------------------------------------- + +ROLE_TEAM_RULES: dict[str, str | None] = { + "be-dev-1": "backend", + "be-dev-2": "backend", + "be-qa": "backend", + "be-pm": "backend", + "be-doc": "backend", + "fe-dev-1": "frontend", + "fe-dev-2": "frontend", + "fe-qa": "frontend", + "fe-pm": "frontend", + "fe-doc": "frontend", + "ux-dev-1": "ux_ui", + "ux-dev-2": "ux_ui", + "ux-qa": "ux_ui", + "ux-pm": "ux_ui", + "ux-doc": "ux_ui", + "main-pm": None, + "product-owner": None, + "head-marketing": None, + "auditor": None, + "ceo": None, +} + + +# --------------------------------------------------------------------------- +# Intent verbs (gateway-facing surface; each composes >=0 atomic actions) +# --------------------------------------------------------------------------- + + +def _next_hint_idle(_t: Any) -> str: + return "idle until next work arrives" + + +def _next_hint_open_pr(_t: Any) -> str: + return "PR opened; call i_am_done(task_id, notes='...') when self-verified" + + +def _next_hint_after_claim(_t: Any) -> str: + return ( + "edit + commit(message) for each meaningful change," + " then open_pr(task_id) and i_am_done(task_id)" + ) + + +def _next_hint_after_plan(_t: Any) -> str: + return ( + "delegate(parent_task_id, title, description, assigned_to," + " team, task_type) for each subtask" + ) + + +def _next_hint_continue_delegating(_t: Any) -> str: + return "continue delegating subtasks, or i_am_idle when done" + + +def _next_hint_qa_review(_t: Any) -> str: + return ( + "review the diff. Then call pass(notes) to accept or fail(issues) to" + " request changes." + ) + + +def _next_hint_dev_revise(_t: Any) -> str: + return "idle - dev will revise and re-submit" + + +def _next_hint_doc_after_claim(_t: Any) -> str: + return ( + "write docs in your workspace, commit them, then call" + " i_documented(task_id, notes, files)" + ) + + +def _next_hint_doc_done(_t: Any) -> str: + return "idle until PM completes" + + +def _next_hint_pm_complete(_t: Any) -> str: + return "merged into target; triage() for next item" + + +def _next_hint_pm_idle(_t: Any) -> str: + return "idle until subtasks finish" + + +# --------------------------------------------------------------------------- +# Context — the third arg to Precondition.check (caller-supplied state) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Context: + """Carrier for caller-supplied state the spec needs to evaluate + preconditions (e.g. the agent's `plan` argument on i_will_work_on, + the journal:decision presence flag). + + Pure data; no behavior. The choreographer builds one of these per + request before calling spec.can_invoke_intent. + """ + + actor_id: UUID | None = None + plan: str | None = None + has_journal_decision: bool = False + has_journal_reflect: bool = False + has_journal_learning: bool = False + progress_count: int = 0 + qa_evidence_inspected: bool = False + actor_slug: str | None = None + original_developer_slug: str | None = None + notes: str | None = None + issues: tuple[str, ...] = () + files: tuple[str, ...] = () + + +# --------------------------------------------------------------------------- +# Pre-defined preconditions wired into IntentSpecs +# --------------------------------------------------------------------------- + + +def _p_has_plan_or_supplied(task: Any, _agent: Any, ctx: Any) -> bool: + return bool(getattr(task, "plan", None)) or bool(getattr(ctx, "plan", None)) + + +def _p_has_commits(task: Any, _agent: Any, _ctx: Any) -> bool: + return bool(getattr(task, "commits", None)) + + +def _p_no_pr_yet(task: Any, _agent: Any, _ctx: Any) -> bool: + return getattr(task, "pr_number", None) is None + + +def _p_owns_task(task: Any, _agent: Any, ctx: Any) -> bool: + return getattr(task, "assigned_to", None) == getattr(ctx, "actor_id", None) + + +PRECONDITION_PLAN = Precondition( + key="plan", + check=_p_has_plan_or_supplied, + remediate=( + "call again with plan=''" + ), + missing_token="plan", +) + +PRECONDITION_COMMITS = Precondition( + key="commits>=1", + check=_p_has_commits, + remediate=( + "commit at least one change before opening a PR — call commit(message='...')" + ), + missing_token="commits>=1", +) + +PRECONDITION_NO_PR = Precondition( + key="no_prior_pr", + check=_p_no_pr_yet, + remediate="a PR is already open for this task; call i_am_done(task_id, notes=...)", + missing_token="no_prior_pr", +) + +PRECONDITION_OWNERSHIP = Precondition( + key="owns_task", + check=_p_owns_task, + remediate="task is not assigned to you; call give_me_work() to find your work", + missing_token="owns_task", +) + + +_INTENT_VERBS: dict[str, IntentSpec] = { + # Phase 1: developer verbs + "give_me_work": IntentSpec( + name="give_me_work", + allowed_roles=frozenset( + { + Role.DEVELOPER, + Role.QA, + Role.DOCUMENTER, + Role.CELL_PM, + Role.MAIN_PM, + } + ), + description="Return your most-actionable task or signal idle.", + composes=(), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "act on the task returned, or i_am_idle if none", + ), + "i_will_work_on": IntentSpec( + name="i_will_work_on", + allowed_roles=_DEV_ROLES, + description=( + "Claim a task, set the plan, and transition to in_progress." + " Atomic - preconditions checked before any state mutation." + ), + composes=("claim", "set_plan", "start"), + extra_preconditions=(PRECONDITION_PLAN,), + side_effects=(), + next_hint=_next_hint_after_claim, + ), + "i_will_plan": IntentSpec( + name="i_will_plan", + allowed_roles=_PM_ROLES, + description=( + "PM mirror of i_will_work_on for parent tasks. Claim, plan," + " transition to in_progress; from there delegate subtasks." + ), + composes=("claim", "set_plan", "start"), + extra_preconditions=(PRECONDITION_PLAN,), + side_effects=(), + next_hint=_next_hint_after_plan, + ), + "delegate": IntentSpec( + name="delegate", + allowed_roles=_PM_ROLES, + description=( + "Create a subtask under the current task. Validates the" + " delegation chain (main_pm->cell_pm; cell_pm->its team's devs)" + " and the assignee-vs-task_type rule (Cell PMs get planning-typed" + " tasks; devs get code/documentation)." + ), + composes=("create_subtask",), + extra_preconditions=(), + side_effects=(), + next_hint=_next_hint_continue_delegating, + ), + "open_pr": IntentSpec( + name="open_pr", + allowed_roles=_DEV_ROLES, + description=( + "Push the branch and open a PR. Atomic - preconditions" + " (assignee, >=1 commit, no prior PR) checked BEFORE any git" + " operation. After success, call i_am_done." + ), + composes=(), + extra_preconditions=( + PRECONDITION_OWNERSHIP, + PRECONDITION_COMMITS, + PRECONDITION_NO_PR, + ), + side_effects=("push_branch", "create_pr"), + next_hint=_next_hint_open_pr, + ), + "i_am_done": IntentSpec( + name="i_am_done", + allowed_roles=_DEV_ROLES, + description=( + "Submit work for QA. Auto-runs in_progress->verifying then" + " verifying->awaiting_qa. Strict - PR must be open (call" + " open_pr first) and >=1 commit." + ), + composes=("submit_verification", "submit_qa"), + extra_preconditions=(PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS), + side_effects=(), + next_hint=_next_hint_idle, + ), + "i_am_blocked": IntentSpec( + name="i_am_blocked", + allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES), + description="Escalate to PM. Logs a struggle journal entry.", + composes=("block",), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "idle - PM will resolve and notify", + ), + "unclaim": IntentSpec( + name="unclaim", + allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + description=( + "Voluntarily release a claim back to pending. The" + " work-in-progress branch is preserved." + ), + composes=(), # special - cleared in service layer + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: ( + "task returned to pending; another agent (or you, fresh) can claim" + ), + ), + "resume": IntentSpec( + name="resume", + allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + description="Resume a paused task you own. paused -> in_progress.", + composes=("resume",), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "resumed; continue working", + ), + "i_am_idle": IntentSpec( + name="i_am_idle", + allowed_roles=frozenset( + _DEV_ROLES + | _QA_ROLES + | _DOC_ROLES + | _PM_ROLES + | {Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.AUDITOR} + ), + description=( + "Signal you have no active work. PMs auto-pause owned in_progress tasks." + ), + composes=(), + extra_preconditions=(), + side_effects=(), + next_hint=_next_hint_idle, + ), + # Phase 2: QA verbs + "claim_review": IntentSpec( + name="claim_review", + allowed_roles=_QA_ROLES, + description="Claim a task in awaiting_qa for review. Returns evidence inline.", + composes=(), + extra_preconditions=(), + side_effects=(), + next_hint=_next_hint_qa_review, + ), + "pass_review": IntentSpec( + name="pass_review", + allowed_roles=_QA_ROLES, + description="Pass QA. Transitions awaiting_qa -> awaiting_documentation.", + composes=("qa_pass",), + extra_preconditions=(), + side_effects=(), + next_hint=_next_hint_idle, + ), + "fail_review": IntentSpec( + name="fail_review", + allowed_roles=_QA_ROLES, + description="Fail QA with concrete issues. Transitions to needs_revision.", + composes=("qa_fail",), + extra_preconditions=(), + side_effects=(), + next_hint=_next_hint_dev_revise, + ), + # Phase 3: documenter verbs + "claim_doc_task": IntentSpec( + name="claim_doc_task", + allowed_roles=_DOC_ROLES, + description="Claim awaiting_documentation. Returns evidence inline.", + composes=(), + extra_preconditions=(), + side_effects=(), + next_hint=_next_hint_doc_after_claim, + ), + "i_documented": IntentSpec( + name="i_documented", + allowed_roles=_DOC_ROLES, + description="Signal docs complete. Transitions to awaiting_pm_review.", + composes=("docs_complete",), + extra_preconditions=(), + side_effects=(), + next_hint=_next_hint_doc_done, + ), + # Phase 4: PM verbs + "complete": IntentSpec( + name="complete", + allowed_roles=_PM_ROLES, + description=( + "Cell PM merges leaf PR + transitions to completed; Main PM" + " merges root PR + escalates to CEO." + ), + composes=("complete",), + extra_preconditions=(), + side_effects=("pr_merge",), + next_hint=_next_hint_pm_complete, + ), + "escalate_up": IntentSpec( + name="escalate_up", + allowed_roles=_PM_ROLES, + description="Escalate to your role's escalation_target.", + composes=(), # special - uses TaskService.escalate + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "idle until escalation target acts", + ), + "escalate_to_ceo": IntentSpec( + name="escalate_to_ceo", + allowed_roles=frozenset( + { + Role.MAIN_PM, + Role.PRODUCT_OWNER, + Role.HEAD_MARKETING, + } + ), + description=( + "Escalate to CEO with reason. Transitions to awaiting_ceo_approval." + ), + composes=("escalate_to_ceo",), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "idle until CEO acts via UI", + ), + "submit_up": IntentSpec( + name="submit_up", + allowed_roles=frozenset({Role.CELL_PM}), + description="Cell PM bubbles a finished cell-scope task up to Main PM.", + composes=("submit_pm_review",), + extra_preconditions=(), + side_effects=("create_pr",), + next_hint=lambda _t: "idle until Main PM reviews", + ), + "unblock": IntentSpec( + name="unblock", + allowed_roles=_PM_ROLES, + description="PM unblocks a blocked task; restores pre-block state.", + composes=("unblock",), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "task restored; original assignee will resume", + ), + "triage": IntentSpec( + name="triage", + allowed_roles=frozenset( + _PM_ROLES | {Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.AUDITOR} + ), + description="List actionable tasks in your scope.", + composes=(), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "act on a listed task or i_am_idle", + ), + "triage_all": IntentSpec( + name="triage_all", + allowed_roles=frozenset({Role.MAIN_PM}), + description="List actionable tasks across all teams (Main PM only).", + composes=(), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "act on a listed task or i_am_idle", + ), +} + + +# --------------------------------------------------------------------------- +# Public lookups +# --------------------------------------------------------------------------- + + +def can_claim(role: Role, task: Any) -> Decision: + """Return Decision for whether `role` can claim `task` right now. + + Thin wrapper around can_invoke_action("claim", ...) for backward + compatibility. The actual enforcement happens in can_invoke_action + when action == "claim", which applies CLAIM_RULES per-role narrowing. + + Rejection-kind disambiguation: + * `not_authorized` — the status belongs to a DIFFERENT role's claim + domain (e.g. dev tries to claim awaiting_qa, which is QA-only), + OR the role has no claim privileges at all. + * `invalid_state` — the role principally CAN claim, but the task is + in a terminal/non-claimable state (e.g. completed, in_progress). + """ + return can_invoke_action(role, "claim", task) + + +def _check_role_status_type( + role: Role, action: str, spec_action: ActionSpec, task: Any +) -> Decision | None: + """Role + source-status + task_type gate. Returns rejection or None.""" + if role not in spec_action.allowed_roles: + return Decision.reject( + kind="not_authorized", + message=f"role '{role.value}' may not call '{action}'", + remediate=( + f"action '{action}' is restricted to:" + f" {sorted(r.value for r in spec_action.allowed_roles)}" + ), + ) + status = Status(getattr(task, "status", "")) + if status not in spec_action.source_statuses: + return Decision.reject( + kind="invalid_state", + message=( + f"task is in '{status.value}', '{action}' requires:" + f" {sorted(s.value for s in spec_action.source_statuses)}" + ), + remediate=( + f"call give_me_work() to find a task in" + f" {sorted(s.value for s in spec_action.source_statuses)}" + ), + ) + if ( + spec_action.allowed_task_types is not None + and TaskType(getattr(task, "task_type", "code")) + not in spec_action.allowed_task_types + ): + return Decision.reject( + kind="invalid_state", + message=( + f"task_type='{task.task_type}' invalid for '{action}'; allowed:" + f" {sorted(t.value for t in spec_action.allowed_task_types)}" + ), + remediate="adjust task_type or pick a different verb", + ) + return None + + +def _check_self_review_and_preconditions( + action: str, spec_action: ActionSpec, task: Any, ctx: Context +) -> Decision | None: + """self_review + declarative preconditions. Returns rejection or None.""" + if spec_action.self_review_block: + original = ctx.original_developer_slug + actor = ctx.actor_slug + if original is not None and actor is not None and original == actor: + return Decision.reject( + kind="self_review", + message=( + f"'{action}' blocked: you are the original developer of" + f" this task ({actor})" + ), + remediate=( + "another agent of this role must perform the review;" + " self-review is not permitted" + ), + ) + missing = [ + p.missing_token + for p in spec_action.preconditions + if not p.check(task, None, ctx) + ] + if missing: + first_missing = next( + p for p in spec_action.preconditions if p.missing_token == missing[0] + ) + return Decision.tracing_gap(missing=missing, remediate=first_missing.remediate) + return None + + +def _check_claim_rules_narrow(role: Role, task: Any) -> Decision | None: + """Per-role narrowing for the `claim` atomic action. + + The atomic `claim` action's source_statuses is the UNION across all + claim-eligible roles (PENDING for dev/doc, NEEDS_REVISION for dev, + AWAITING_QA for qa, AWAITING_DOCUMENTATION for doc, etc.). + CLAIM_RULES narrows by role. Without this narrowing + can_invoke_action("claim", ...) would let a developer claim + awaiting_qa just because QA can. + + not_authorized vs invalid_state disambiguation matches `can_claim`: + if some other role can claim from this status, the rejection is + role-mismatch (not_authorized); else it's a wrong-state issue. + """ + status = Status(getattr(task, "status", "")) + role_claim_statuses = CLAIM_RULES.get(role, frozenset()) + if status in role_claim_statuses: + return None + allowed_list = sorted(s.value for s in role_claim_statuses) + other_role_owns_status = any( + status in r_statuses for r, r_statuses in CLAIM_RULES.items() if r != role + ) + if other_role_owns_status: + return Decision.reject( + kind="not_authorized", + message=( + f"role '{role.value}' may not claim from status" + f" '{status.value}'; that status is reserved for another role" + ), + remediate=(f"call give_me_work() to find a task in one of: {allowed_list}"), + ) + return Decision.reject( + kind="invalid_state", + message=( + f"role '{role.value}' cannot claim from status '{status.value}'" + f"; allowed: {allowed_list}" + ), + remediate=(f"call give_me_work() to find a task in one of: {allowed_list}"), + ) + + +def can_invoke_action( + role: Role, action: str, task: Any, context: Context | None = None +) -> Decision: + """Decide whether `role` can invoke atomic `action` on `task`. + + Order: action exists -> role allowed -> source status allowed -> + task_type allowed -> self_review check -> preconditions -> + claim rules (if action == "claim"). + """ + spec_action = _ATOMIC_ACTIONS.get(action) + if spec_action is None: + return Decision.reject( + kind="invalid_state", + message=f"unknown action '{action}'", + remediate="action is not declared in the lifecycle spec", + ) + rejection = _check_role_status_type(role, action, spec_action, task) + if rejection is not None: + return rejection + ctx = context or Context() + rejection = _check_self_review_and_preconditions(action, spec_action, task, ctx) + if rejection is not None: + return rejection + if action == "claim": + rejection = _check_claim_rules_narrow(role, task) + if rejection is not None: + return rejection + return Decision.allow() + + +def _check_intent_preconditions( + spec_intent: IntentSpec, task: Any, ctx: Context +) -> Decision | None: + """Verb-level extra_preconditions gate. Returns rejection or None.""" + missing = [ + p.missing_token + for p in spec_intent.extra_preconditions + if not p.check(task, None, ctx) + ] + if not missing: + return None + first_missing = next( + p for p in spec_intent.extra_preconditions if p.missing_token == missing[0] + ) + return Decision.tracing_gap(missing=missing, remediate=first_missing.remediate) + + +def can_invoke_intent( + role: Role, intent: str, task: Any, context: Context | None = None +) -> Decision: + """Decide whether `role` can invoke gateway intent verb `intent` on `task`. + + Composition: intent's allowed_roles -> task in source statuses of the + FIRST composed action (or any of the composed if `composes==()`) -> + intent's extra_preconditions -> each composed atomic action's + can_invoke_action check. + """ + spec_intent = _INTENT_VERBS.get(intent) + if spec_intent is None: + return Decision.reject( + kind="invalid_state", + message=f"unknown intent verb '{intent}'", + remediate="verb is not declared in the lifecycle spec", + ) + if role not in spec_intent.allowed_roles: + return Decision.reject( + kind="not_authorized", + message=f"role '{role.value}' may not call '{intent}'", + remediate=( + f"verb '{intent}' is restricted to:" + f" {sorted(r.value for r in spec_intent.allowed_roles)}" + ), + ) + ctx = context or Context() + rejection = _check_intent_preconditions(spec_intent, task, ctx) + if rejection is not None: + return rejection + # Composed atomic actions: all must be invocable from current state. + # For composition, only check the FIRST action's source-status — subsequent + # actions transition through their target_status. + if spec_intent.composes: + first_action = spec_intent.composes[0] + d = can_invoke_action(role, first_action, task, ctx) + if not d.allowed: + return d + # Special handling for claim-like verbs with empty composition (claim_review, + # claim_doc_task). These verbs don't compose "claim" action, but still need + # to enforce claim-status rules via CLAIM_RULES narrowing. + elif intent in ("claim_review", "claim_doc_task"): + rejection = _check_claim_rules_narrow(role, task) + if rejection is not None: + return rejection + return Decision.allow() + + +def valid_next_verbs(role: Role, task: Any) -> list[str]: + """Return sorted list of verb names `role` can usefully call on `task` now. + + This is the role+state applicability list — caller-supplied + preconditions (plan, ownership, commits, etc.) are NOT evaluated + here. The agent-facing semantics: "these are the verbs that fit + your role and the task's current status; missing preconditions + surface as `tracing_gap` when you actually invoke them." + """ + out: list[str] = [] + for name, iv in _INTENT_VERBS.items(): + if role not in iv.allowed_roles: + continue + if iv.composes: + first_action = iv.composes[0] + d = can_invoke_action(role, first_action, task) + # Skip ONLY for state-incompatibility; tracing_gap (missing + # action-level preconditions) is also surfaced lazily. + if not d.allowed and d.rejection_kind in ( + "not_authorized", + "invalid_state", + ): + continue + out.append(name) + return sorted(out) + + +def composed_actions_for(intent: str) -> tuple[str, ...]: + spec_intent = _INTENT_VERBS.get(intent) + if spec_intent is None: + raise KeyError(f"unknown intent verb '{intent}'") + return spec_intent.composes + + +def intents_for_role(role: Role) -> tuple[str, ...]: + """Sorted tuple of intent verbs declared for `role` (regardless of state). + + Used by role_config.py to build per-role MCP manifests. + """ + return tuple( + sorted(name for name, iv in _INTENT_VERBS.items() if role in iv.allowed_roles) + ) + + +def status_after(action: str, current: Status) -> Status | None: + """The post-`action` status, or None if `action` doesn't transition.""" + spec_action = _ATOMIC_ACTIONS.get(action) + if spec_action is None: + return None + if current not in spec_action.source_statuses: + return None + return spec_action.target_status + + +# --------------------------------------------------------------------------- +# Known-debt tracking — Phase 3 invariant +# --------------------------------------------------------------------------- + +UNMIGRATED: frozenset[str] = frozenset( + { + "enforcement.task_lifecycle._LEGACY_OPERATIONAL_EDGES", + "enforcement.task_lifecycle._LEGACY_ROLE_GATES", + } +) +"""Names of consumers / data still NOT migrated to the spec. + +Each entry represents a real production path the spec doesn't yet cover. +Validator (`_check_unmigrated_is_subset`) asserts UNMIGRATED is a subset +of _KNOWN_UNMIGRATED_CONSUMERS — adding an entry not in the known set +fails import. Phase 3's terminal invariant is `UNMIGRATED == frozenset()`, +locked in as a permanent test once the last entry moves to the spec. +""" + +_KNOWN_UNMIGRATED_CONSUMERS: frozenset[str] = frozenset( + { + "enforcement.task_lifecycle._LEGACY_OPERATIONAL_EDGES", + "enforcement.task_lifecycle._LEGACY_ROLE_GATES", + } +) + + +# --------------------------------------------------------------------------- +# Import-time self-consistency checks +# --------------------------------------------------------------------------- +# +# Validating the spec at module-load time means a misconfigured spec +# prevents the orchestrator container from starting — by design. The +# validators themselves live in ``roboco.foundation._validate_lifecycle`` +# (a sibling of ``foundation/_validate.py`` for identity); placing them +# alongside the identity validators would create an import cycle because +# ``roboco.foundation.__init__`` eagerly imports ``foundation/_validate``. +from roboco.foundation._validate_lifecycle import ( # noqa: E402 + run_all_lifecycle_validators as _run_all_lifecycle_validators, +) + +_run_all_lifecycle_validators() diff --git a/roboco/foundation/policy/task_completeness.py b/roboco/foundation/policy/task_completeness.py new file mode 100644 index 00000000..dff9833f --- /dev/null +++ b/roboco/foundation/policy/task_completeness.py @@ -0,0 +1,298 @@ +"""Task completeness rules — "ALL DETAILS MUST BE FILLED" mandate, encoded. + +Single source of truth for which Task fields are required at which +lifecycle moment (create, delegate, claim, open_pr, i_am_done). Defense +in depth: + 1. Pydantic schemas reject under-filled requests at the boundary. + 2. Service-layer raises TaskCompletenessError on construction. + 3. Gateway returns Envelope.incomplete_input with field_hints (the + "interrogation" pattern from spec §5.2.1). + +The DENYLIST catches placeholder strings agents have used to evade the +spirit of the rule — including the exact phrase from the deleted +services/task.py:5061-5062 silent fallback. +""" + +from __future__ import annotations + +import contextlib +import re +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +from roboco.foundation import identity + + +class FieldRule(StrEnum): + NON_EMPTY_STRING = "non_empty_string" + MIN_LENGTH = "min_length" + NON_EMPTY_LIST = "non_empty_list" + EXPLICITLY_DECLARED = "explicitly_declared" + + +@dataclass(frozen=True) +class FieldRequirement: + field: str + rule: FieldRule + value: int | None = None + hint: str = "" + + +@dataclass(frozen=True) +class CompletenessSpec: + name: str + requires: tuple[FieldRequirement, ...] + + +@dataclass(frozen=True) +class CompletenessResult: + passed: bool + missing: list[str] = field(default_factory=list) + field_hints: dict[str, str] = field(default_factory=dict) + + +class TaskCompletenessError(Exception): + """Raised by service-layer when a task fails completeness rules.""" + + def __init__( + self, + missing: list[str], + field_hints: dict[str, str] | None = None, + message: str | None = None, + ) -> None: + self.missing = list(missing) + self.field_hints = dict(field_hints or {}) + super().__init__(message or f"task missing required fields: {missing}") + + +# Denylist — exact placeholder strings rejected as known evasions. +DENYLIST_AC_PHRASES: frozenset[str] = frozenset( + { + "completed and reviewed by assignee", + "task complete", + "see description", + "see title", + "tbd", + "todo", + } +) + +DENYLIST_DESCRIPTION_PATTERNS: tuple[str, ...] = ( + r"^see title$", + r"^same as title$", + r"^todo$", + r"^tbd$", + r"^n/?a$", + r"^pending$", + r"^placeholder$", +) + + +_HINT_DESCRIPTION = ( + "1-2 sentence summary of the change and why it's needed (e.g. " + "'Add /v1/orders endpoint returning paginated orders for the dashboard')." +) +_HINT_ACCEPTANCE_CRITERIA = ( + "non-empty list[str]; each item describes a verifiable outcome (e.g. " + "'returns 401 when token absent'). Do NOT use placeholder strings like " + "'completed and reviewed by assignee' — that's a known evasion phrase the " + "gateway rejects." +) +_HINT_TASK_TYPE = ( + "one of: code | documentation | research | planning | design | administrative" +) +_HINT_NATURE = "one of: technical | non_technical" +_HINT_ESTIMATED_COMPLEXITY = ( + "one of: low | medium | high, based on file count + dependency depth + " + "novelty (low = 1-2 files, medium = 3-10 files or new module, high = " + "cross-cell, schema-touching, security, or migration)" +) +_HINT_TEAM = ( + "one of: backend | frontend | ux_ui (cell-routed work) | board | main_pm | " + "fullstack (cross-cell). Note: 'marketing' is legacy seed-data only — no " + "agent declares it; 'system' is an orchestrator sentinel, not for tasks." +) +_HINT_TITLE = "single line, <= 200 chars, descriptive" + + +TASK_AT_CREATE: CompletenessSpec = CompletenessSpec( + name="task_at_create", + requires=( + FieldRequirement("title", FieldRule.MIN_LENGTH, 1, _HINT_TITLE), + FieldRequirement("description", FieldRule.MIN_LENGTH, 20, _HINT_DESCRIPTION), + FieldRequirement( + "acceptance_criteria", + FieldRule.NON_EMPTY_LIST, + hint=_HINT_ACCEPTANCE_CRITERIA, + ), + FieldRequirement( + "task_type", FieldRule.EXPLICITLY_DECLARED, hint=_HINT_TASK_TYPE + ), + FieldRequirement("nature", FieldRule.EXPLICITLY_DECLARED, hint=_HINT_NATURE), + FieldRequirement( + "estimated_complexity", + FieldRule.EXPLICITLY_DECLARED, + hint=_HINT_ESTIMATED_COMPLEXITY, + ), + FieldRequirement("team", FieldRule.EXPLICITLY_DECLARED, hint=_HINT_TEAM), + ), +) + + +def _check_explicitly_declared(value: Any) -> tuple[bool, str | None]: + if value is None: + return False, "field is None / missing" + return True, None + + +def _check_non_empty_string(value: Any) -> tuple[bool, str | None]: + if not isinstance(value, str) or not value.strip(): + return False, "must be a non-empty string" + return True, None + + +def _check_min_length(value: Any, minimum: int) -> tuple[bool, str | None]: + if not isinstance(value, str): + return False, f"must be a string of length >= {minimum}" + stripped_len = len(value.strip()) + if stripped_len < minimum: + return False, f"must be at least {minimum} chars (got {stripped_len})" + return True, None + + +def _check_non_empty_list(value: Any) -> tuple[bool, str | None]: + if not isinstance(value, list) or len(value) == 0: + return False, "must be a non-empty list" + return True, None + + +def _check_field(req: FieldRequirement, value: Any) -> tuple[bool, str | None]: + """Return (passed, problem_description). problem_description is None on pass.""" + if req.rule is FieldRule.EXPLICITLY_DECLARED: + return _check_explicitly_declared(value) + if req.rule is FieldRule.NON_EMPTY_STRING: + return _check_non_empty_string(value) + if req.rule is FieldRule.MIN_LENGTH: + return _check_min_length(value, req.value or 0) + return _check_non_empty_list(value) + + +def _matches_denylist_ac(items: Any) -> bool: + """True if any item in `items` is a denylisted placeholder phrase.""" + if not isinstance(items, list): + return False + return any( + isinstance(item, str) and item.strip().lower() in DENYLIST_AC_PHRASES + for item in items + ) + + +def _matches_denylist_description(text: Any) -> bool: + """True if the description matches any denylist regex.""" + if not isinstance(text, str): + return False + text_stripped = text.strip().lower() + return any( + re.fullmatch(pattern, text_stripped) + for pattern in DENYLIST_DESCRIPTION_PATTERNS + ) + + +def check(spec: CompletenessSpec, task: Any) -> CompletenessResult: + """Run every requirement in `spec` against `task`. Return a CompletenessResult. + + `task` may be a Pydantic model, a dataclass, or any object with the + expected attributes (used in tests via SimpleNamespace). + """ + missing: list[str] = [] + field_hints: dict[str, str] = {} + + for req in spec.requires: + value = getattr(task, req.field, None) + + # Field-level rule check. + passed, _problem = _check_field(req, value) + if not passed: + missing.append(req.field) + field_hints[req.field] = req.hint + continue + + # Denylist checks (post-rule). + if req.field == "acceptance_criteria" and _matches_denylist_ac(value): + missing.append("acceptance_criteria") + field_hints["acceptance_criteria"] = ( + "rejected: placeholder phrase from the legacy silent fallback. " + + req.hint + ) + continue + if req.field == "description" and _matches_denylist_description(value): + missing.append("description") + field_hints["description"] = ( + "rejected: placeholder/empty phrase. " + req.hint + ) + continue + + return CompletenessResult( + passed=len(missing) == 0, + missing=missing, + field_hints=field_hints, + ) + + +def fill_team_from_assignee(payload: dict[str, Any]) -> dict[str, Any]: + """Auto-fill `team` from `assigned_to` slug, never overwriting an explicit value. + + Returns a NEW dict (does not mutate input). Auto-fill is best-effort: + if `assigned_to` is unknown, returns the payload unchanged. The + downstream completeness check then rejects on missing `team`. + """ + out = dict(payload) + if out.get("team") is not None and out.get("team") != "": + return out # caller was explicit; don't override + slug = out.get("assigned_to") + if not isinstance(slug, str): + return out + # Unknown slug -> leave team unset; downstream completeness check rejects. + with contextlib.suppress(KeyError): + out["team"] = identity.team_for_slug(slug).value + return out + + +def fill_priority_from_parent( + payload: dict[str, Any], parent: Any | None +) -> dict[str, Any]: + """Auto-fill `priority` from parent task, falling back to medium (2). + + Sets `__priority_inherited=True` (sentinel for the gateway to log a + journal:note about the inheritance — keeps the audit trail clean). + """ + out = dict(payload) + if out.get("priority") is not None: + return out # caller was explicit + if ( + parent is not None + and hasattr(parent, "priority") + and parent.priority is not None + ): + out["priority"] = parent.priority + else: + out["priority"] = 2 # medium (default) + out["__priority_inherited"] = True + return out + + +def fill_parent_from_active_task( + payload: dict[str, Any], active_task_id: str | None +) -> dict[str, Any]: + """Auto-fill `parent_task_id` from the caller's active task. + + Used on `delegate(...)` calls where the caller's active task IS the + parent. Never overwrites an explicit value. + """ + out = dict(payload) + if out.get("parent_task_id"): + return out + if active_task_id: + out["parent_task_id"] = active_task_id + return out diff --git a/roboco/foundation/policy/tracing.py b/roboco/foundation/policy/tracing.py new file mode 100644 index 00000000..a287a5c1 --- /dev/null +++ b/roboco/foundation/policy/tracing.py @@ -0,0 +1,337 @@ +"""Tracing-gate policy — verb→required-set table + check_requirements. + +Replaces (in Task 13): + - services/gateway/tracing_gate.py (the entire module) + - 6 inline `journal:decision` checks scattered in choreographer/_impl.py + - inline gates in choreographer/qa.py (QA pass/fail) + - inline gates in choreographer/doc.py (i_documented) + +Adds (per spec §11 P1-P4 pre-gateway parity restorations): + - JOURNAL_NOTE_AT_CLAIM — required by i_will_work_on + - JOURNAL_DECISION_AT_CLAIM — required by i_will_plan + - JOURNAL_DURING_WORK_AT_LEAST_ONE — required by i_am_done +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class Requirement(StrEnum): + PLAN = "plan" + COMMITS_AT_LEAST_ONE = "commits>=1" + PR_OPEN = "pr_open" + PROGRESS_AT_LEAST_ONE = "progress>=1" + JOURNAL_REFLECT = "journal:reflect" + JOURNAL_DECISION = "journal:decision" + JOURNAL_LEARNING = "journal:learning" + JOURNAL_STRUGGLE = "journal:struggle" + JOURNAL_NOTE_AT_CLAIM = "journal:note_at_claim" + JOURNAL_DECISION_AT_CLAIM = "journal:decision_at_claim" + JOURNAL_DURING_WORK_AT_LEAST_ONE = "journal:during_work>=1" + ACCEPTANCE_CRITERIA_ADDRESSED = "acceptance_criteria_addressed" + QA_NOTES_MIN_CHARS = "qa_notes>=min" + QA_EVIDENCE_INSPECTED = "qa_evidence_inspected" + DOCS_NOTES_MIN_CHARS = "docs_notes>=min" + DOCS_FILES_NON_EMPTY = "docs_files_non_empty" + SELF_VERIFIED = "self_verified" + NOTES_MIN_CHARS = "notes>=min" + SUBTASKS_TERMINAL = "subtasks_terminal" + + +@dataclass(frozen=True) +class GateContext: + """Ambient inputs the checker needs that don't live on the Task model.""" + + journal_reflect_present: bool = False + journal_decision_present: bool = False + journal_learning_present: bool = False + journal_struggle_present: bool = False + journal_note_at_claim_present: bool = False + journal_during_work_count: int = 0 + qa_notes_min_chars: int = 80 + docs_notes_min_chars: int = 20 + notes_min_chars: int = 20 + + +@dataclass(frozen=True) +class GateResult: + passed: bool + missing: list[str] = field(default_factory=list) + + +Checker = Callable[[Any, GateContext], list[str]] + + +def _check_plan(task: Any, _ctx: GateContext) -> list[str]: + return [] if getattr(task, "plan", None) else ["plan"] + + +def _check_commits(task: Any, _ctx: GateContext) -> list[str]: + commits = getattr(task, "commits", None) or [] + return [] if len(commits) >= 1 else ["commits>=1"] + + +def _check_pr_open(task: Any, _ctx: GateContext) -> list[str]: + return [] if getattr(task, "pr_number", None) else ["pr_open"] + + +def _check_progress(task: Any, _ctx: GateContext) -> list[str]: + progress = getattr(task, "progress_updates", None) or [] + return [] if len(progress) >= 1 else ["progress>=1"] + + +def _check_journal_reflect(_task: Any, ctx: GateContext) -> list[str]: + return [] if ctx.journal_reflect_present else ["journal:reflect"] + + +def _check_journal_decision(_task: Any, ctx: GateContext) -> list[str]: + return [] if ctx.journal_decision_present else ["journal:decision"] + + +def _check_journal_learning(_task: Any, ctx: GateContext) -> list[str]: + return [] if ctx.journal_learning_present else ["journal:learning"] + + +def _check_journal_struggle(_task: Any, ctx: GateContext) -> list[str]: + return [] if ctx.journal_struggle_present else ["journal:struggle"] + + +def _check_journal_note_at_claim(_task: Any, ctx: GateContext) -> list[str]: + return [] if ctx.journal_note_at_claim_present else ["journal:note_at_claim"] + + +def _check_journal_decision_at_claim(_task: Any, ctx: GateContext) -> list[str]: + # Reuse JOURNAL_DECISION presence flag; "_at_claim" timing is the + # caller's responsibility (i_will_plan only requires a decision entry + # exists for this task by this agent — its position in the timeline + # is enforced by the verb's call order, not the gate). + return [] if ctx.journal_decision_present else ["journal:decision_at_claim"] + + +def _check_journal_during_work(_task: Any, ctx: GateContext) -> list[str]: + return [] if ctx.journal_during_work_count >= 1 else ["journal:during_work>=1"] + + +def _unaddressed_criteria(task: Any) -> list[str]: + criteria = list(getattr(task, "acceptance_criteria", []) or []) + status_rows = list(getattr(task, "acceptance_criteria_status", []) or []) + addressed = { + s["criterion"] + for s in status_rows + if isinstance(s, dict) and s.get("referencing_artifact_id") + } + return [c for c in criteria if c not in addressed] + + +def _check_acceptance_criteria(task: Any, ctx: GateContext) -> list[str]: + """Reflect-note serves as the addressing artifact when explicit + per-criterion citation is absent. See spec §9 item 1.""" + if ctx.journal_reflect_present: + return [] + return [f"acceptance_criterion:{c}" for c in _unaddressed_criteria(task)] + + +def _check_qa_notes_min_chars(task: Any, ctx: GateContext) -> list[str]: + notes = getattr(task, "qa_notes", "") or "" + return [] if len(notes) >= ctx.qa_notes_min_chars else ["qa_notes>=min"] + + +def _check_qa_evidence_inspected(task: Any, _ctx: GateContext) -> list[str]: + return ( + [] + if getattr(task, "qa_evidence_inspected", False) + else ["qa_evidence_inspected"] + ) + + +def _check_docs_notes_min_chars(task: Any, ctx: GateContext) -> list[str]: + notes = getattr(task, "dev_notes", "") or "" + return [] if len(notes) >= ctx.docs_notes_min_chars else ["docs_notes>=min"] + + +def _check_docs_files_non_empty(task: Any, _ctx: GateContext) -> list[str]: + docs = getattr(task, "documents", None) or [] + return [] if len(docs) >= 1 else ["docs_files_non_empty"] + + +def _check_self_verified(task: Any, _ctx: GateContext) -> list[str]: + return [] if getattr(task, "self_verified", False) else ["self_verified"] + + +def _check_notes_min_chars(task: Any, ctx: GateContext) -> list[str]: + notes = getattr(task, "notes", "") or "" + return [] if len(notes) >= ctx.notes_min_chars else ["notes>=min"] + + +def _check_subtasks_terminal(task: Any, _ctx: GateContext) -> list[str]: + """Caller passes a task whose `_subtasks_all_terminal` boolean is set + by the choreographer based on a DB query. Validator just reads it.""" + return ( + [] if getattr(task, "_subtasks_all_terminal", False) else ["subtasks_terminal"] + ) + + +_CHECKERS: dict[Requirement, Checker] = { + Requirement.PLAN: _check_plan, + Requirement.COMMITS_AT_LEAST_ONE: _check_commits, + Requirement.PR_OPEN: _check_pr_open, + Requirement.PROGRESS_AT_LEAST_ONE: _check_progress, + Requirement.JOURNAL_REFLECT: _check_journal_reflect, + Requirement.JOURNAL_DECISION: _check_journal_decision, + Requirement.JOURNAL_LEARNING: _check_journal_learning, + Requirement.JOURNAL_STRUGGLE: _check_journal_struggle, + Requirement.JOURNAL_NOTE_AT_CLAIM: _check_journal_note_at_claim, + Requirement.JOURNAL_DECISION_AT_CLAIM: _check_journal_decision_at_claim, + Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE: _check_journal_during_work, + Requirement.ACCEPTANCE_CRITERIA_ADDRESSED: _check_acceptance_criteria, + Requirement.QA_NOTES_MIN_CHARS: _check_qa_notes_min_chars, + Requirement.QA_EVIDENCE_INSPECTED: _check_qa_evidence_inspected, + Requirement.DOCS_NOTES_MIN_CHARS: _check_docs_notes_min_chars, + Requirement.DOCS_FILES_NON_EMPTY: _check_docs_files_non_empty, + Requirement.SELF_VERIFIED: _check_self_verified, + Requirement.NOTES_MIN_CHARS: _check_notes_min_chars, + Requirement.SUBTASKS_TERMINAL: _check_subtasks_terminal, +} + + +def check_requirements( + *, + task: Any, + requirements: list[Requirement], + ctx: GateContext | None = None, +) -> GateResult: + """Run every requirement in `requirements` against `task` + `ctx`. + + Returns GateResult(passed, missing) — `missing` is empty on pass. + """ + context = ctx or GateContext() + missing: list[str] = [] + for req in requirements: + missing.extend(_CHECKERS[req](task, context)) + return GateResult(passed=len(missing) == 0, missing=missing) + + +# Verb name → required Requirements (single source of truth). +VERB_REQUIREMENTS: dict[str, frozenset[Requirement]] = { + # Developer claim — pre-gateway DEVELOPER.md required a work_log entry on claim. + # PLAN mirrors spec.PRECONDITION_PLAN at the tracing layer (single source of truth). + "i_will_work_on": frozenset( + { + Requirement.PLAN, + Requirement.JOURNAL_NOTE_AT_CLAIM, + } + ), + # PM claim — pre-gateway PM.md required a journal:decision on plan. + "i_will_plan": frozenset( + { + Requirement.PLAN, + Requirement.JOURNAL_DECISION_AT_CLAIM, + } + ), + # PM delegate — pre-gateway PM.md required journal:decision before each delegate. + "delegate": frozenset({Requirement.JOURNAL_DECISION}), + # Developer submit — adds JOURNAL_DURING_WORK_AT_LEAST_ONE for mid-flight cadence. + # SELF_VERIFIED is set by the auto-run in_progress→verifying transition; it + # stays in the required-set as a defense-in-depth backstop. + "i_am_done": frozenset( + { + Requirement.COMMITS_AT_LEAST_ONE, + Requirement.PR_OPEN, + Requirement.PROGRESS_AT_LEAST_ONE, + Requirement.SELF_VERIFIED, + Requirement.JOURNAL_REFLECT, + Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE, + Requirement.ACCEPTANCE_CRITERIA_ADDRESSED, + } + ), + # QA pass/fail. + "pass_review": frozenset( + { + Requirement.QA_NOTES_MIN_CHARS, + Requirement.QA_EVIDENCE_INSPECTED, + Requirement.JOURNAL_LEARNING, + } + ), + "fail_review": frozenset( + { + Requirement.QA_NOTES_MIN_CHARS, + Requirement.QA_EVIDENCE_INSPECTED, + Requirement.JOURNAL_LEARNING, + } + ), + # Doc submit. + "i_documented": frozenset( + { + Requirement.DOCS_FILES_NON_EMPTY, + Requirement.DOCS_NOTES_MIN_CHARS, + Requirement.JOURNAL_REFLECT, + } + ), + # PM submit-up — adds JOURNAL_REFLECT (pre-gateway required decision AND reflect). + "submit_up": frozenset( + { + Requirement.SUBTASKS_TERMINAL, + Requirement.JOURNAL_DECISION, + Requirement.JOURNAL_REFLECT, + Requirement.NOTES_MIN_CHARS, + } + ), + # PM complete — adds JOURNAL_REFLECT (parity with submit_up). + "complete": frozenset( + { + Requirement.JOURNAL_DECISION, + Requirement.JOURNAL_REFLECT, + Requirement.NOTES_MIN_CHARS, + } + ), + # PM unblock — was inline at _impl.py:2192-2200; now declared. + "unblock": frozenset({Requirement.JOURNAL_DECISION}), + # PM escalate up — was inline. + "escalate_up": frozenset({Requirement.JOURNAL_DECISION}), + # Board/MainPM escalate to CEO. + "escalate_to_ceo": frozenset({Requirement.JOURNAL_DECISION}), + # Developer block — pre-gateway required journal:struggle. + "i_am_blocked": frozenset({Requirement.JOURNAL_STRUGGLE}), +} + + +# Verbs that intentionally have no tracing requirement (read-only / discovery / +# pure state moves). Each entry is a deliberate decision, not an oversight. +VERBS_WITHOUT_TRACING: frozenset[str] = frozenset( + { + "give_me_work", # discovery — no state change + "triage", # read-only listing + "triage_all", # read-only listing + "evidence", # read-only evidence dump + "i_am_idle", # signal only + "unclaim", # voluntary release; no rationale required + "resume", # pure state move paused→in_progress + # claim_review's tracing applies on pass_review / fail_review. + "claim_review", + # claim_doc_task's tracing applies on i_documented. + "claim_doc_task", + # open_pr is a mechanical push+open; preconditions are inline. + "open_pr", + } +) + + +def requirements_for(verb: str) -> frozenset[Requirement]: + """Lookup the required-set for a verb. + + Raises KeyError when the verb is neither in VERB_REQUIREMENTS nor in + VERBS_WITHOUT_TRACING — caller should never reach a verb name unknown to + foundation. + """ + if verb in VERB_REQUIREMENTS: + return VERB_REQUIREMENTS[verb] + if verb in VERBS_WITHOUT_TRACING: + return frozenset() + raise KeyError( + f"unknown verb in tracing table: {verb!r} " + f"(known: {sorted(set(VERB_REQUIREMENTS) | VERBS_WITHOUT_TRACING)})" + ) diff --git a/roboco/mcp/flow_server.py b/roboco/mcp/flow_server.py index e2f41658..eab9e221 100644 --- a/roboco/mcp/flow_server.py +++ b/roboco/mcp/flow_server.py @@ -28,10 +28,25 @@ ORCHESTRATOR_URL = os.environ.get( "ROBOCO_ORCHESTRATOR_URL", "http://roboco-orchestrator:8000", ) +# Where the per-agent SDK server lives (per-container loopback). The +# flow server POSTs /verb/attempted here so the per-verb circuit breaker +# can record rejections and tell us when to substitute circuit_open. +SDK_URL = os.environ.get("ROBOCO_SDK_URL", "http://localhost:9000") AGENT_ID = os.environ["ROBOCO_AGENT_ID"] AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"] _TIMEOUT = 30 +# Tight timeout for SDK loopback — the SDK is a local sidecar; anything +# slower than 2s is unhealthy and the gateway path must not stall on it. +_SDK_TIMEOUT = 2.0 + +# Envelope error kinds that count toward the per-verb circuit breaker. +# Mirrors agent_sdk.server._CIRCUIT_REJECTION_KINDS; the SDK is the +# authoritative side, but we filter here too so we only emit one POST +# for kinds the SDK will actually count. +_CIRCUIT_REJECTION_KINDS: frozenset[str] = frozenset( + {"tracing_gap", "invalid_state", "not_authorized", "incomplete_input"} +) mcp = FastMCP("roboco-flow") log = structlog.get_logger() @@ -61,6 +76,13 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: in either case so agents see ``remediate`` / ``missing`` even on a 4xx response. Only raises if the response has no parseable body (e.g., a 5xx with HTML error page or a network failure). + + Rejection envelopes (error in _CIRCUIT_REJECTION_KINDS) are forwarded + to the local SDK's /verb/attempted so the per-verb circuit breaker + can track them. If the SDK reports the breaker is now open, the + original rejection is REPLACED with the circuit_open envelope before + being returned to the agent — preventing further hammering on a verb + that won't succeed. Successful (ok) envelopes never touch the SDK. """ with httpx.Client(timeout=_TIMEOUT) as client: response = client.post( @@ -86,8 +108,78 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: ), "missing": [], } + # Outside the orchestrator client context so the SDK call is its own + # connection — keeps semantics independent and timeouts separated. + return _record_and_check_circuit(path, body, payload) + + +def _verb_from_path(path: str) -> str: + """Extract the verb name from a role-scoped flow path. + + ``/api/v2/flow//`` → ````. Returns the original + path if it doesn't match the expected shape (defensive — the breaker + falls open downstream when the verb is unrecognized). + """ + return path.rsplit("/", 1)[-1] + + +def _record_and_check_circuit( + path: str, + body: dict[str, Any], + payload: dict[str, Any], +) -> dict[str, Any]: + """Forward a gateway rejection to the SDK breaker; maybe substitute. + + For successful (ok) envelopes this is a no-op — only rejections of + kind tracing_gap / invalid_state / not_authorized / incomplete_input + are reported. When the SDK responds with ``open=true`` we replace the + original rejection with the wire-format ``circuit_open`` envelope so + the agent stops retrying. + + Best-effort: SDK unreachable, slow, or malformed response → return + the original payload. The breaker is a safety net; it must never + break the gateway path. + """ + rejection_kind = payload.get("error") + if rejection_kind not in _CIRCUIT_REJECTION_KINDS: return payload + verb = _verb_from_path(path) + task_id = body.get("task_id") + try: + with httpx.Client(timeout=_SDK_TIMEOUT) as client: + resp = client.post( + f"{SDK_URL}/verb/attempted", + json={ + "verb": verb, + "task_id": str(task_id) if task_id is not None else None, + "rejection_kind": rejection_kind, + }, + ) + status = resp.json() + except (httpx.HTTPError, OSError, ValueError, json.JSONDecodeError) as exc: + # Fail open: agent sees the original rejection. Log so operators + # notice an SDK that's down — the gateway keeps working. + log.warning( + "flow_server: SDK /verb/attempted unreachable; breaker bypassed", + verb=verb, + task_id=task_id, + error=str(exc), + ) + return payload + + if status.get("open") and isinstance(status.get("circuit_envelope"), dict): + circuit_env: dict[str, Any] = status["circuit_envelope"] + log.info( + "flow_server: circuit_open substituted for rejection", + verb=verb, + task_id=task_id, + attempts=status.get("attempts"), + limit=status.get("limit"), + ) + return circuit_env + return payload + # Board route serves Product Owner + Head Marketing under one prefix. # AgentRole values that map to a different URL segment go here; everything diff --git a/roboco/models/base.py b/roboco/models/base.py index 65da8044..13d0fca7 100644 --- a/roboco/models/base.py +++ b/roboco/models/base.py @@ -11,6 +11,18 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field +# AgentRole and Team are canonicalized in roboco/foundation/identity.py. +# These bindings keep existing imports (`from roboco.models.base import +# AgentRole, Team`) working during the migration. SQLAlchemy column types +# bound as `sa.Enum(AgentRole, name="agentrole")` continue to work because +# Python identity is preserved — AgentRole IS identity.Role (same class +# object), so SQLAlchemy maps to the same postgres `agentrole` enum. +# Removed in Phase 4 housekeeping after every consumer is migrated. +from roboco.foundation import identity + +AgentRole = identity.Role +Team = identity.Team + # ============================================================================= # ENUMS # ============================================================================= @@ -73,42 +85,6 @@ class Complexity(StrEnum): HIGH = "high" -class Team(StrEnum): - """Organizational teams/cells.""" - - BACKEND = "backend" - FRONTEND = "frontend" - UX_UI = "ux_ui" - FULLSTACK = "fullstack" - MAIN_PM = "main_pm" - BOARD = "board" - MARKETING = "marketing" - - -class AgentRole(StrEnum): - """Agent roles in the organization.""" - - # System (internal orchestrator operations) - SYSTEM = "system" - - # Executive - CEO = "ceo" - - # Board - PRODUCT_OWNER = "product_owner" - HEAD_MARKETING = "head_marketing" - AUDITOR = "auditor" - - # Management - MAIN_PM = "main_pm" - CELL_PM = "cell_pm" - - # Cell Members - DEVELOPER = "developer" - QA = "qa" - DOCUMENTER = "documenter" - - class AgentStatus(StrEnum): """Agent operational states.""" diff --git a/roboco/models/task.py b/roboco/models/task.py index b1f0aebc..2dfc428a 100644 --- a/roboco/models/task.py +++ b/roboco/models/task.py @@ -303,19 +303,32 @@ class Task(TimestampMixin): class TaskCreate(RobocoBase): - """Schema for creating a new task.""" + """Schema for creating a new task. + + Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE` + so under-filled payloads fail at the request boundary — no silent + defaults, no "code"/"technical"/"medium" fallbacks. The 2026-05-08 trace + showed agents omitting task_type and the old default of "code" + deadlocking the lifecycle; the same silent-default trap existed for + nature ("technical") and complexity ("medium"). Force callers to + declare intent. + """ title: str = Field(..., min_length=1, max_length=200) - description: str + # 20-char minimum mirrors TASK_AT_CREATE.description (MIN_LENGTH=20). + # Forces a real one-line summary instead of "x" or "see title". + description: str = Field(..., min_length=20) acceptance_criteria: list[str] = Field(..., min_length=1) - team: Team + team: Team = Field(...) priority: int = Field(default=2, ge=0, le=3) parent_task_id: UUID | None = None # Accepts an agent UUID or an agent slug (e.g. "main-pm", "be-dev-1"). # The route handler resolves slugs to UUIDs before persisting. assigned_to: str | None = None target_date: datetime | None = None - estimated_complexity: Complexity = Complexity.MEDIUM + # task_type, nature, estimated_complexity are EXPLICITLY_DECLARED in + # TASK_AT_CREATE — no defaults. + estimated_complexity: Complexity = Field(...) status: TaskStatus | None = None # PM can set 'backlog' for subtasks needing setup # Ordering and dependencies @@ -328,8 +341,8 @@ class TaskCreate(RobocoBase): ) # Git configuration (all tasks follow git workflow) - task_type: TaskType = TaskType.CODE - nature: TaskNature = TaskNature.TECHNICAL + task_type: TaskType = Field(...) + nature: TaskNature = Field(...) project_id: UUID # Required - all tasks need a project @@ -367,28 +380,32 @@ class TaskUpdate(RobocoBase): @dataclass class TaskCreateRequest: - """Request data for creating a task via TaskService.""" + """Request data for creating a task via TaskService. - # Required fields (no defaults) + Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE`. + `task_type`, `nature`, and `estimated_complexity` are required — + no silent "code"/"technical"/"medium" fallbacks. The 2026-05-08 trace + showed those defaults deadlocking the lifecycle. + """ + + # Required fields (no defaults) — all of TASK_AT_CREATE plus owner/project. title: str description: str acceptance_criteria: list[str] team: Team created_by: UUID project_id: UUID # Required - all tasks need a project for git workflow + task_type: TaskType + nature: TaskNature + estimated_complexity: Complexity # Optional fields (with defaults) priority: int = 2 parent_task_id: UUID | None = None assigned_to: UUID | None = None target_date: datetime | None = None - estimated_complexity: Complexity = field(default=Complexity.MEDIUM) status: TaskStatus | None = None # PM can set BACKLOG for subtasks # Ordering and dependencies sequence: int = 0 # Order within siblings (lower = first) dependency_ids: list[UUID] = field(default_factory=list) - - # Git configuration (all tasks follow git workflow) - task_type: TaskType = field(default=TaskType.CODE) - nature: TaskNature = field(default=TaskNature.TECHNICAL) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index e51f3359..dd9ab464 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -39,6 +39,8 @@ from roboco.agents_config import ( get_escalation_target, ) from roboco.config import settings +from roboco.foundation import identity as _foundation +from roboco.foundation.policy.agent_loop import DEFAULT_BUDGET as _AGENT_LOOP_BUDGET from roboco.models import AgentRole, Team from roboco.models.runtime import ( MODEL_MAP, @@ -2312,29 +2314,20 @@ class AgentOrchestrator: } return role_map.get(agent_id, agent_id) - # Static team mappings for management agents (ROUTING purposes) - # NOTE: This differs from agents_config.get_agent_team() intentionally. - # agents_config returns None for management (no team for permissions). - # This map returns routing categories for dispatcher task assignment. + # Slug -> team string for ROUTING purposes. Derived from + # foundation.AGENTS so adding/renaming an agent edits exactly one + # file (foundation/identity.py). The dispatcher relies on this for + # task assignment routing categories. _AGENT_TEAM_MAP: ClassVar[dict[str, str]] = { - "main-pm": "main_pm", - "product-owner": "board", - "auditor": "board", - "head-marketing": "marketing", + slug: row.team.value for slug, row in _foundation.AGENTS.items() } def _get_agent_team(self, agent_id: str) -> str | None: - """Get team from agent_id.""" - # Check static mappings first - if agent_id in self._AGENT_TEAM_MAP: - return self._AGENT_TEAM_MAP[agent_id] - - # Check cell prefixes - prefix_map = {"be-": "backend", "fe-": "frontend", "ux-": "ux_ui"} - for prefix, team in prefix_map.items(): - if agent_id.startswith(prefix): - return team - return None + """Get team from agent_id. Returns None for unknown slugs.""" + try: + return _foundation.team_for_slug(agent_id).value + except KeyError: + return None def _resolve_agent_slug(self, agent_id_or_uuid: str) -> str: """Resolve agent UUID to slug. Returns input if already a slug.""" @@ -3037,7 +3030,7 @@ Start by: """Block non-trivial root tasks routed to a dev without subtasks.""" complexity = task.get("estimated_complexity", "low") parent_task_id = task.get("parent_task_id") - if complexity not in ("medium", "high", "critical") or parent_task_id: + if complexity not in ("medium", "high") or parent_task_id: return None task_id = task.get("id") try: @@ -3336,7 +3329,7 @@ Start by: if ( self._has_cross_cell_keywords(text) - or complexity in ("high", "critical") + or complexity == "high" or not team or team == "all" ): @@ -3804,7 +3797,8 @@ Start now: evidence(task_id="{task_id}") } ) - _PM_RESPAWN_MAX_UNPRODUCTIVE = 3 + # Use foundation's default; keep the local name for back-compat. + _PM_RESPAWN_MAX_UNPRODUCTIVE = _AGENT_LOOP_BUDGET.pm_respawn_max_unproductive async def _pm_respawn_should_gate( self, agent_slug: str, task: dict[str, Any] @@ -4406,15 +4400,22 @@ Never `commit`, never write code, never run `git`. PMs coordinate. # would silently spawn the dev. Reject the dispatch if the # assignee's role doesn't match the task type — the PM that # mis-assigned needs to fix it before any agent runs. - if agent_slug and not self._dev_dispatch_role_matches(task, agent_slug): - logger.warning( - "dev dispatch: role/task_type mismatch — skipping spawn", - task_id=task.get("id"), - task_type=task.get("task_type"), - assignee_slug=agent_slug, - assignee_role=get_agent_role(agent_slug), - ) - return + # Tasks owned by PM/board/QA roles aren't this dispatcher's lane; + # `_dispatch_pm_work` and the QA-pool path own them. Silently skip + # so the warning only fires on actual dev/doc misassignments. + if agent_slug: + assignee_role = get_agent_role(agent_slug) + if assignee_role not in ("developer", "documenter", "unknown"): + return + if not self._dev_dispatch_role_matches(task, agent_slug): + logger.warning( + "dev dispatch: role/task_type mismatch — skipping spawn", + task_id=task.get("id"), + task_type=task.get("task_type"), + assignee_slug=agent_slug, + assignee_role=assignee_role, + ) + return if agent_slug and status in ( "needs_revision", @@ -5047,7 +5048,7 @@ Never `commit`, never write code, never run `git`. PMs coordinate. return [] complexity = task.get("estimated_complexity", "low") - is_low_complexity = complexity not in ("medium", "high", "critical") + is_low_complexity = complexity not in ("medium", "high") if is_low_complexity or task.get("parent_task_id"): return [] @@ -5458,16 +5459,30 @@ Your job: """ def _build_a2a_prompt(self, notification: dict[str, Any]) -> str: - """Build initial prompt for handling an A2A (Agent-to-Agent) request.""" + """Build initial prompt for handling an A2A (Agent-to-Agent) request. + + Reads `priority` directly off the notification row (set by + NotificationService.send_a2a_notification). Pre-Phase-3 this + consumed a non-existent `metadata.urgent` and always rendered + urgency_note=False; the column-level priority is now the source + of truth. + """ notif_id = notification.get("id", "unknown") from_agent = notification.get("from_agent", "unknown") body = notification.get("body", "No message provided") related_task_id = notification.get("related_task_id") metadata = notification.get("metadata", {}) skill = metadata.get("skill", "general") - urgent = metadata.get("urgent", False) + priority_raw = notification.get("priority", "normal") - urgency_note = "**URGENT** - This request has priority.\n\n" if urgent else "" + # URGENT gets the bold attention-grabber; HIGH gets a quieter + # "higher priority" hint; NORMAL gets no prefix. + if priority_raw == "urgent": + urgency_note = "**URGENT** - This request has priority.\n\n" + elif priority_raw == "high": + urgency_note = "**HIGH PRIORITY** - Please handle promptly.\n\n" + else: + urgency_note = "" task_note = f"RELATED TASK: {related_task_id}\n" if related_task_id else "" return f"""You have received an A2A (Agent-to-Agent) REQUEST. diff --git a/roboco/seeds/initial_data.py b/roboco/seeds/initial_data.py index 0bc9af11..0b816d62 100644 --- a/roboco/seeds/initial_data.py +++ b/roboco/seeds/initial_data.py @@ -7,82 +7,54 @@ Separates data definitions from bootstrap logic. from typing import Any +from roboco.foundation import identity as _foundation +from roboco.foundation.policy import communications as _comms + # ============================================================================= # DEFAULT CHANNELS +# +# Channel topology (slug, description, type, membership) is canonicalized in +# `roboco.foundation.policy.communications.CHANNELS`. The DEFAULT_CHANNELS +# list and CHANNEL_MEMBERSHIPS dict below derive from that catalog at module +# load. The only seed-only field is `name` — a presentation string the +# foundation does not (and should not) own. It lives in +# `_CHANNEL_PRESENTATION` and is the only edit needed to rename a channel +# label. # ============================================================================= -DEFAULT_CHANNELS = [ - # Cell channels - { - "slug": "backend-cell", - "name": "Backend Cell", - "description": "Backend development team channel", - "channel_type": "cell", - }, - { - "slug": "frontend-cell", - "name": "Frontend Cell", - "description": "Frontend development team channel", - "channel_type": "cell", - }, - { - "slug": "uxui-cell", - "name": "UX/UI Cell", - "description": "UX/UI design team channel", - "channel_type": "cell", - }, - # Cross-cell role channels - { - "slug": "dev-all", - "name": "All Developers", - "description": "Cross-cell developer discussion", - "channel_type": "cross_cell", - }, - { - "slug": "qa-all", - "name": "All QA", - "description": "Cross-cell QA discussion", - "channel_type": "cross_cell", - }, - { - "slug": "pm-all", - "name": "All PMs", - "description": "Cross-cell PM coordination", - "channel_type": "cross_cell", - }, - { - "slug": "doc-all", - "name": "All Documenters", - "description": "Cross-cell documentation discussion", - "channel_type": "cross_cell", - }, - # Management channels - { - "slug": "main-pm-board", - "name": "Main PM & Board", - "description": "Main PM and Board communication", - "channel_type": "management", - }, - { - "slug": "board-private", - "name": "Board Private", - "description": "Board-only discussions", - "channel_type": "management", - }, - # Special channels - { - "slug": "announcements", - "name": "Announcements", - "description": "Company-wide announcements (read-only for most)", - "channel_type": "special", - }, - { - "slug": "all-hands", - "name": "All Hands", - "description": "Company-wide open discussion", - "channel_type": "special", - }, -] +# Per-channel display name. Slug, description, type, and membership are all +# sourced from foundation.CHANNELS; this dict only carries presentation. +_CHANNEL_PRESENTATION: dict[str, str] = { + "backend-cell": "Backend Cell", + "frontend-cell": "Frontend Cell", + "uxui-cell": "UX/UI Cell", + "dev-all": "All Developers", + "qa-all": "All QA", + "pm-all": "All PMs", + "doc-all": "All Documenters", + "main-pm-board": "Main PM & Board", + "board-private": "Board Private", + "announcements": "Announcements", + "all-hands": "All Hands", +} + + +def _build_default_channels() -> list[dict[str, Any]]: + """Compose DEFAULT_CHANNELS rows from foundation specs + display names.""" + return [ + { + "slug": spec.slug, + "name": _CHANNEL_PRESENTATION[slug], + "description": spec.description, + # Legacy DB seeder keys this as `channel_type`; preserve the name + # so create_channels(session) keeps working unchanged. + "channel_type": spec.type.value, + } + for slug, spec in _comms.CHANNELS.items() + ] + + +DEFAULT_CHANNELS: list[dict[str, Any]] = _build_default_channels() # ============================================================================= @@ -93,271 +65,166 @@ DEFAULT_CHANNELS = [ # - Task assignments # - Container orchestration # -# UUID scheme: -# - 0000-0000: CEO (human) +# UUID scheme (encoded in roboco/foundation/identity.py:AGENTS): +# - 0000-0000: System sentinel + CEO (human) # - 0001-000X: Backend cell # - 0002-000X: Frontend cell # - 0003-000X: UX/UI cell # - 0004-000X: Board/Management +# +# UUIDs, role, and team are all sourced from foundation.AGENTS so that +# adding/renaming an agent is a single-file edit. Per-agent presentation +# (display name) lives below in _AGENT_PRESENTATION because it is the +# only field foundation does not (and should not) own. # ============================================================================= -# Static agent UUIDs - NEVER change these after initial deployment -AGENT_UUIDS = { - # System sentinel — used as `from_agent` for orchestrator-generated - # notifications (blocker escalations, QA-fail notices, doc-ready, - # handoff, etc.). The notifications.from_agent column is NOT NULL + - # FK to agents.id, so these system-originated notifications need a - # real agent row or the INSERT fails. Keep the UUID as all-zeros as - # a clear "this is not a person" sentinel. - "system": "00000000-0000-0000-0000-000000000000", - # CEO (Human) - "ceo": "00000000-0000-0000-0000-000000000001", - # Backend Cell - "be-dev-1": "00000000-0000-0000-0001-000000000001", - "be-dev-2": "00000000-0000-0000-0001-000000000002", - "be-qa": "00000000-0000-0000-0001-000000000003", - "be-pm": "00000000-0000-0000-0001-000000000004", - "be-doc": "00000000-0000-0000-0001-000000000005", - # Frontend Cell - "fe-dev-1": "00000000-0000-0000-0002-000000000001", - "fe-dev-2": "00000000-0000-0000-0002-000000000002", - "fe-qa": "00000000-0000-0000-0002-000000000003", - "fe-pm": "00000000-0000-0000-0002-000000000004", - "fe-doc": "00000000-0000-0000-0002-000000000005", - # UX/UI Cell - "ux-dev-1": "00000000-0000-0000-0003-000000000001", - "ux-dev-2": "00000000-0000-0000-0003-000000000002", - "ux-qa": "00000000-0000-0000-0003-000000000003", - "ux-pm": "00000000-0000-0000-0003-000000000004", - "ux-doc": "00000000-0000-0000-0003-000000000005", - # Board / Management - "main-pm": "00000000-0000-0000-0004-000000000001", - "product-owner": "00000000-0000-0000-0004-000000000002", - "head-marketing": "00000000-0000-0000-0004-000000000003", - "auditor": "00000000-0000-0000-0004-000000000004", +# Derived AGENT_UUIDS — string-keyed for backward compat with consumers +# that index by slug and read string-typed UUIDs. +AGENT_UUIDS: dict[str, str] = { + slug: str(row.uuid) for slug, row in _foundation.AGENTS.items() } -DEFAULT_AGENTS: list[dict[str, Any]] = [ - # System sentinel (not a spawnable agent; used as sender for - # orchestrator-generated notifications and audit events). - { - "id": AGENT_UUIDS["system"], - "slug": "system", - "name": "System", - "role": "system", - "team": None, - }, - # Backend Cell - { - "id": AGENT_UUIDS["be-dev-1"], - "slug": "be-dev-1", - "name": "Backend Developer 1", - "role": "developer", - "team": "backend", - }, - { - "id": AGENT_UUIDS["be-dev-2"], - "slug": "be-dev-2", - "name": "Backend Developer 2", - "role": "developer", - "team": "backend", - }, - { - "id": AGENT_UUIDS["be-qa"], - "slug": "be-qa", - "name": "Backend QA", - "role": "qa", - "team": "backend", - }, - { - "id": AGENT_UUIDS["be-pm"], - "slug": "be-pm", - "name": "Backend PM", - "role": "cell_pm", - "team": "backend", - }, - { - "id": AGENT_UUIDS["be-doc"], - "slug": "be-doc", - "name": "Backend Documenter", - "role": "documenter", - "team": "backend", - }, - # Frontend Cell - { - "id": AGENT_UUIDS["fe-dev-1"], - "slug": "fe-dev-1", - "name": "Frontend Developer 1", - "role": "developer", - "team": "frontend", - }, - { - "id": AGENT_UUIDS["fe-dev-2"], - "slug": "fe-dev-2", - "name": "Frontend Developer 2", - "role": "developer", - "team": "frontend", - }, - { - "id": AGENT_UUIDS["fe-qa"], - "slug": "fe-qa", - "name": "Frontend QA", - "role": "qa", - "team": "frontend", - }, - { - "id": AGENT_UUIDS["fe-pm"], - "slug": "fe-pm", - "name": "Frontend PM", - "role": "cell_pm", - "team": "frontend", - }, - { - "id": AGENT_UUIDS["fe-doc"], - "slug": "fe-doc", - "name": "Frontend Documenter", - "role": "documenter", - "team": "frontend", - }, - # UX/UI Cell - { - "id": AGENT_UUIDS["ux-dev-1"], - "slug": "ux-dev-1", - "name": "UX/UI Developer 1", - "role": "developer", - "team": "ux_ui", - }, - { - "id": AGENT_UUIDS["ux-dev-2"], - "slug": "ux-dev-2", - "name": "UX/UI Developer 2", - "role": "developer", - "team": "ux_ui", - }, - { - "id": AGENT_UUIDS["ux-qa"], - "slug": "ux-qa", - "name": "UX/UI QA", - "role": "qa", - "team": "ux_ui", - }, - { - "id": AGENT_UUIDS["ux-pm"], - "slug": "ux-pm", - "name": "UX/UI PM", - "role": "cell_pm", - "team": "ux_ui", - }, - { - "id": AGENT_UUIDS["ux-doc"], - "slug": "ux-doc", - "name": "UX/UI Documenter", - "role": "documenter", - "team": "ux_ui", - }, - # Board / Management - { - "id": AGENT_UUIDS["main-pm"], - "slug": "main-pm", - "name": "Main PM", - "role": "main_pm", - "team": "main_pm", # Cross-cell coordination - }, - { - "id": AGENT_UUIDS["product-owner"], - "slug": "product-owner", - "name": "Product Owner", - "role": "product_owner", - "team": "board", - }, - { - "id": AGENT_UUIDS["head-marketing"], - "slug": "head-marketing", - "name": "Head of Marketing", - "role": "head_marketing", - "team": "marketing", - }, - { - "id": AGENT_UUIDS["auditor"], - "slug": "auditor", - "name": "Auditor", - "role": "auditor", - "team": "board", # Silent observer, board-level access - }, - # CEO (Human) - { - "id": AGENT_UUIDS["ceo"], - "slug": "ceo", - "name": "Renzo", - "role": "ceo", - "team": None, - }, -] +# Per-agent display names. Anything role/team/uuid is sourced from +# foundation; this dict only carries presentation strings. +_AGENT_PRESENTATION: dict[str, dict[str, Any]] = { + "ceo": {"name": "Renzo"}, + "be-dev-1": {"name": "Backend Developer 1"}, + "be-dev-2": {"name": "Backend Developer 2"}, + "be-qa": {"name": "Backend QA"}, + "be-pm": {"name": "Backend PM"}, + "be-doc": {"name": "Backend Documenter"}, + "fe-dev-1": {"name": "Frontend Developer 1"}, + "fe-dev-2": {"name": "Frontend Developer 2"}, + "fe-qa": {"name": "Frontend QA"}, + "fe-pm": {"name": "Frontend PM"}, + "fe-doc": {"name": "Frontend Documenter"}, + "ux-dev-1": {"name": "UX/UI Developer 1"}, + "ux-dev-2": {"name": "UX/UI Developer 2"}, + "ux-qa": {"name": "UX/UI QA"}, + "ux-pm": {"name": "UX/UI PM"}, + "ux-doc": {"name": "UX/UI Documenter"}, + "main-pm": {"name": "Main PM"}, + "product-owner": {"name": "Product Owner"}, + "head-marketing": {"name": "Head of Marketing"}, + "auditor": {"name": "Auditor"}, +} + + +def _build_default_agents() -> list[dict[str, Any]]: + """Compose DEFAULT_AGENTS rows from foundation + presentation metadata. + + The system sentinel is appended as a literal because: + 1. The postgres `team` enum does not include 'system' — only + 'backend|frontend|ux_ui|board|main_pm|fullstack|marketing'. + Seeding with team='system' would fail at INSERT. + 2. The system row is a from_agent FK target, never a participant. + """ + rows: list[dict[str, Any]] = [] + for slug, row in _foundation.AGENTS.items(): + if slug == "system": + continue + rows.append( + { + "id": str(row.uuid), + "slug": slug, + "role": row.role.value, + "team": row.team.value, + **_AGENT_PRESENTATION[slug], + } + ) + # System sentinel — kept as a literal so we can pass team=None into the + # DB without colliding with the postgres `team` enum (which does not + # have a 'system' value). + rows.append( + { + "id": str(_foundation.AGENTS["system"].uuid), + "slug": "system", + "name": "System", + "role": _foundation.AGENTS["system"].role.value, + "team": None, + } + ) + return rows + + +DEFAULT_AGENTS: list[dict[str, Any]] = _build_default_agents() # ============================================================================= # CHANNEL MEMBERSHIP # -# This populates the database channel.members/writers fields for initial setup. +# Populates the database channel.members / channel.writers / silent_observers +# fields at seed time. Membership is now derived from +# foundation.policy.communications.CHANNELS — adding/removing an agent from a +# channel is a single edit in the foundation catalog. # # NOTE: This is SEPARATE from roboco/agents_config.py CHANNEL_ACCESS which is -# the runtime permission source of truth. The relationship is: -# -# 1. CHANNEL_MEMBERSHIPS (here) -> populates database channel.members -# 2. CHANNEL_ACCESS (agents_config) -> used by PermissionService for checks -# 3. Privileged roles (CEO, Auditor, Main PM) bypass membership via -# has_privileged_access() in services/permissions.py -# -# This means main-pm isn't listed in board-private here but CAN read it -# via the privileged role bypass. The seed data is for UI/listing purposes, -# while CHANNEL_ACCESS is the actual permission enforcement. +# the runtime permission source of truth. Both derive from the same +# foundation catalog. Privileged roles (CEO, Auditor, Main PM) still bypass +# membership at runtime via has_privileged_access() in services/permissions.py. # ============================================================================= CEO_AGENT_ID = AGENT_UUIDS["ceo"] -CHANNEL_MEMBERSHIPS = { - # Cell channels - cell members + CEO - "backend-cell": ["be-dev-1", "be-dev-2", "be-qa", "be-pm", "be-doc", "ceo"], - "frontend-cell": ["fe-dev-1", "fe-dev-2", "fe-qa", "fe-pm", "fe-doc", "ceo"], - "uxui-cell": ["ux-dev-1", "ux-dev-2", "ux-qa", "ux-pm", "ux-doc", "ceo"], - # Role channels + CEO - "dev-all": [ - "be-dev-1", - "be-dev-2", - "fe-dev-1", - "fe-dev-2", - "ux-dev-1", - "ux-dev-2", - "ceo", - ], - "qa-all": ["be-qa", "fe-qa", "ux-qa", "ceo"], - "pm-all": ["be-pm", "fe-pm", "ux-pm", "main-pm", "ceo"], - "doc-all": ["be-doc", "fe-doc", "ux-doc", "ceo"], - # Management channels + CEO - "main-pm-board": [ - "main-pm", - "product-owner", - "head-marketing", - "auditor", - "ceo", - ], - "board-private": ["product-owner", "head-marketing", "auditor", "ceo"], - # Broadcast channels - everyone human-or-agent (system sentinel - # excluded — it's a from_agent placeholder, not a participant). - "announcements": [a["slug"] for a in DEFAULT_AGENTS if a["slug"] != "system"], - "all-hands": [a["slug"] for a in DEFAULT_AGENTS if a["slug"] != "system"], -} +# Cell-member roles subject to a channel's team_scope. Cross-cell roles +# (MAIN_PM, AUDITOR, CEO, board) are NOT filtered by team_scope. Mirrors the +# rule in agents_config._TEAM_SCOPED_ROLES; duplicated here to avoid a +# circular import (agents_config already imports AGENT_UUIDS from this +# module). +_TEAM_SCOPED_ROLES: frozenset[_foundation.Role] = frozenset( + { + _foundation.Role.DEVELOPER, + _foundation.Role.QA, + _foundation.Role.DOCUMENTER, + _foundation.Role.CELL_PM, + } +) -# Auditor has silent read access to cell/role channels -AUDITOR_SILENT_ACCESS = [ - "backend-cell", - "frontend-cell", - "uxui-cell", - "dev-all", - "qa-all", - "pm-all", - "doc-all", -] + +def _slugs_for_role_set( + role_set: frozenset[_foundation.Role], + team_scope: _foundation.Team | None, +) -> list[str]: + """Expand a role-set to sorted agent slugs, honoring optional team_scope. + + A slug qualifies when its role is in `role_set` AND, if its role is in + _TEAM_SCOPED_ROLES and team_scope is set, its team matches team_scope. + The system sentinel is always excluded. + """ + out: list[str] = [] + for slug, row in _foundation.AGENTS.items(): + if slug == "system": + continue + if row.role not in role_set: + continue + if ( + team_scope is not None + and row.role in _TEAM_SCOPED_ROLES + and row.team != team_scope + ): + continue + out.append(slug) + return sorted(out) + + +def _build_channel_memberships() -> dict[str, list[str]]: + """Per-channel sorted member slugs derived from foundation.CHANNELS.""" + return { + slug: _slugs_for_role_set(spec.read_roles, spec.team_scope) + for slug, spec in _comms.CHANNELS.items() + } + + +CHANNEL_MEMBERSHIPS: dict[str, list[str]] = _build_channel_memberships() + + +# Auditor silent-read channels — derived from CHANNELS where AUDITOR appears +# in silent_roles. +AUDITOR_SILENT_ACCESS: list[str] = sorted( + slug + for slug, spec in _comms.CHANNELS.items() + if _foundation.Role.AUDITOR in spec.silent_roles +) # ============================================================================= diff --git a/roboco/services/a2a.py b/roboco/services/a2a.py index 3cb8847a..919ce170 100644 --- a/roboco/services/a2a.py +++ b/roboco/services/a2a.py @@ -646,8 +646,15 @@ class A2AService: if not allowed: hint = get_a2a_route_hint(from_agent, target_agent) raise ValueError(f"{error_msg} Hint: {hint}") - urgent_from_config = config.urgent if config else False - urgent = urgent_from_config or metadata.get("urgent", False) + # Priority parsing: full tristate (NORMAL/HIGH/URGENT) survives + # end-to-end after P3 Task 9. Resolution rules live in + # foundation.policy.communications.parse_priority. + from roboco.foundation.policy.communications import parse_priority + + legacy_urgent = bool( + (config and config.urgent) or metadata.get("urgent", False) + ) + priority = parse_priority(metadata.get("priority"), legacy_urgent) # Extract message content _, _, message_text = self.extract_message_text(message) @@ -658,7 +665,7 @@ class A2AService: from_agent=from_agent, target_agent=target_agent, skill=skill, - urgent=urgent, + priority=priority.value, ) # Create notification - orchestrator dispatcher will handle spawning @@ -670,7 +677,7 @@ class A2AService: "to_agent": target_agent or "", "skill": skill, "message": message_text, - "urgent": urgent, + "priority": priority, }, ) diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 5178569f..9ba0ec6b 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -17,11 +17,11 @@ from uuid import UUID import structlog +from roboco.foundation.policy import lifecycle as spec_module +from roboco.services.gateway.choreographer._verb_runner import VerbRunner from roboco.services.gateway.claim_guards import ( already_active_guard, paused_tasks_guard, - pm_cannot_execute_code_guard, - role_typed_claim_guard, sibling_sequence_guard, ) from roboco.services.gateway.envelope import Envelope @@ -32,19 +32,38 @@ from roboco.services.gateway.evidence_builder import ( ) from roboco.services.gateway.merge_chain import parent_branch_for from roboco.services.gateway.remediation import ( + hint_for_evidence_not_inspected, + hint_for_missing_doc_files, + hint_for_missing_journal_decision, + hint_for_missing_journal_learning, hint_for_missing_progress, + hint_for_missing_qa_notes, hint_for_missing_reflect, + hint_for_short_doc_notes, hint_for_unaddressed_acceptance_criteria, ) -from roboco.services.gateway.tracing_gate import ( - GateContext, - Requirement, - check_requirements, -) logger = structlog.get_logger() +def _extract_original_developer(task: Any) -> str | None: + """Pull the original_developer slug out of a task's quick_context, if any. + + The ``quick_context`` blob carries handoff hints written by prior + actors; "original_developer:" is one such hint. Used by the + spec's self-review precondition (a documenter who is also the + original developer cannot self-doc). + """ + qc = getattr(task, "quick_context", None) or "" + marker = "original_developer:" + if marker not in qc: + return None + tail = qc.split(marker, 1)[1].strip() + if not tail: + return None + return tail.split()[0] or None + + @dataclass(frozen=True) class ChoreographerDeps: """All service dependencies bundled for Choreographer. @@ -64,14 +83,75 @@ class ChoreographerDeps: evidence_repo: Any +@dataclass(frozen=True) +class _ClaimPlanStartContext: + """Bundle of fields shared by ``i_will_work_on`` / ``i_will_plan`` helpers. + + Both verbs compose the same (claim, set_plan, start) sequence and + share gating + recovery branches; they only differ in role gate + (DEV vs PM) and verb name on rejections / next_hint. Frozen so the + helper sites can't mutate caller state and to keep PLR0913 (too + many positional args) at bay. + """ + + agent_id: UUID + task_id: UUID + task: Any + role_str: str + briefing: dict[str, Any] + plan: str | None + verb_name: str + + +@dataclass(frozen=True) +class _IAmDoneContext: + """Bundle of fields the ``i_am_done`` helper sites all need. + + Frozen so the helper sites can't mutate caller state and to keep + PLR0913 (too many positional args) at bay across the dispatcher + body and its recovery branch. + """ + + agent_id: UUID + task_id: UUID + task: Any + role_str: str + briefing: dict[str, Any] + notes: str + + +@dataclass(frozen=True) +class _ReassignedCtx: + """Bundle of fields the ``_reassigned_rejection`` helper inspects. + + Shared between ``unclaim`` and ``resume`` (Task 6 fix in commit + a5d358d). Frozen so the helper site can't mutate caller state and + to keep PLR0913 (too many positional args) at bay. + """ + + task: Any + agent_id: UUID + task_id: UUID + role_str: str + briefing: dict[str, Any] + upstream_hint: str + + @dataclass(frozen=True) class DelegateInputs: """Bundle of fields the ``delegate`` verb receives from the route layer. - `task_type` has no default — the v2 schema enforces this at the HTTP - boundary, but defaulting here too would let direct callers (tests, - other internal code) silently pick 'code' and recreate the - 2026-05-08 deadlock. + Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE`: + `task_type` and `nature` have no defaults — the v2 schema enforces both at + the HTTP boundary (Task 15), and defaulting here too would let direct + callers (tests, internal code) silently pick `'code'`/`'technical'` and + recreate the 2026-05-08 deadlock. + + Optional fields (`acceptance_criteria=None`, `nature=None`) survive the + construction step and are then rejected by the gateway-side + `task_completeness.check` before reaching `_create_subtask_from_inputs` + (Task 19): the rejection takes the form of `Envelope.incomplete_input` + so the agent receives a structured field-by-field guide. """ title: str @@ -79,6 +159,7 @@ class DelegateInputs: assigned_to: str team: str task_type: str + nature: str | None = None acceptance_criteria: list[str] | None = None estimated_complexity: str = "medium" @@ -137,6 +218,35 @@ class Choreographer: if task_id is not None: await self.task.heartbeat(task_id) + @staticmethod + def _reassigned_rejection( + ctx: _ReassignedCtx, + ) -> Envelope | None: + """Build the "task reassigned by upstream verb" rejection envelope. + + Shared between ``unclaim`` and ``resume`` (Task 6 fix in commit + a5d358d). The spec doesn't model "task got reassigned out from + under you by an upstream verb" — when the spec gate accepts but + ``task.assigned_to != agent_id``, this helper produces the + envelope with the load-bearing "current owner" hint and + verb-specific upstream remediate text. Returns ``None`` when the + caller still owns the task. + """ + task = ctx.task + if task.assigned_to == ctx.agent_id: + return None + current_owner = ( + str(task.assigned_to) if task.assigned_to is not None else "" + ) + return Envelope.not_authorized( + message=( + f"task {ctx.task_id} is no longer assigned to you " + f"(current owner: {current_owner})" + ), + remediate=ctx.upstream_hint, + context_briefing=ctx.briefing, + ).with_introspection(task=task, role=ctx.role_str) + async def _emit_rejection( self, env: Envelope, @@ -240,23 +350,27 @@ class Choreographer: ) return build_context_briefing(inputs) - @staticmethod - def _run_role_guards( - role: str, task_type: str, *, skip_pm_code: bool, skip_role_typed: bool + async def _run_claim_guards( + self, + *, + agent_id: UUID, + task: Any, + skip_sequence: bool = False, ) -> Envelope | None: - """Sync role-based guards (pm_cannot_execute_code, role_typed).""" - if not skip_pm_code and ( - guard := pm_cannot_execute_code_guard(role, task_type) - ): - return guard - if not skip_role_typed and (guard := role_typed_claim_guard(role, task_type)): - return guard - return None + """Run concurrency-invariant claim guards. Returns rejection or None. - async def _run_claim_concurrency_guards( - self, agent_id: UUID, task: Any, *, skip_sequence: bool - ) -> Envelope | None: - """Async concurrency-based guards (already_active, paused, sequence).""" + Scope: only system-level concurrency invariants the lifecycle spec + does NOT model. Role/state/task_type checks now route through + ``spec.can_invoke_action`` (CLAIM_RULES + ActionSpec.allowed_task_types) + in the verb's spec gate; the former role-typed and + pm_cannot_execute_code guards have been deleted (Task 27, 2026-05-10). + + Pre-gateway location: _helpers.py:124-204 + claim.py:121-180. + + ``skip_sequence`` lets resumption-of-already-claimed-task call sites + skip the sibling-sequence check (the sequence was already validated + on the original claim). + """ in_progress = await self.task.list_in_progress_for_agent(agent_id) if guard := already_active_guard(in_progress, task.id): return guard @@ -269,42 +383,6 @@ class Choreographer: return guard return None - async def _run_claim_guards( - self, - *, - agent_id: UUID, - task: Any, - skip_role_typed: bool = False, - skip_pm_code: bool = False, - skip_sequence: bool = False, - ) -> Envelope | None: - """Run claim-time guards (Gate Set A). Returns rejection or None. - - Pre-gateway location: _helpers.py:124-204 + claim.py:121-180. - - Optional skip flags isolate guards that don't apply to a given verb: - - skip_role_typed: i_will_plan/claim_review/claim_doc_task have their - own role checks; only i_will_work_on uses role_typed_claim_guard. - - skip_pm_code: claim_review/claim_doc_task call sites cannot be PMs - to begin with; pm_cannot_execute_code is meaningless there. - - skip_sequence: some verbs (resumption of an already-claimed task) - do not need to re-validate sibling order. - """ - agent = await self.task.agent_for(agent_id) - role = agent.role if agent is not None else "developer" - task_type = str(task.task_type) - - if guard := self._run_role_guards( - role, - task_type, - skip_pm_code=skip_pm_code, - skip_role_typed=skip_role_typed, - ): - return guard - return await self._run_claim_concurrency_guards( - agent_id, task, skip_sequence=skip_sequence - ) - async def _fetch_siblings(self, task: Any) -> list[Any]: """Fetch sibling tasks for the sequence-order guard. @@ -358,145 +436,215 @@ class Choreographer: context_briefing=await self._briefing_for(agent_id, task_id), ) - async def _i_will_work_on_pending( - self, - agent_id: UUID, - task_id: UUID, - t: Any, - plan: str | None, - briefing: dict[str, Any], - ) -> tuple[Envelope | None, Any]: - """Pending-branch dispatch for i_will_work_on. Atomic: validates - the plan precondition BEFORE calling claim() so a missing-plan - rejection doesn't leave the task in `claimed` with no plan. + def _verb_runner(self) -> VerbRunner: + """Construct a VerbRunner bound to this Choreographer's services. - Pre-fix (2026-05-09 smoke Bug A): claim() ran first, then the - plan check failed → task was stuck in `claimed` because the - `_i_will_work_on_claimed` branch (the natural retry path) had - no plan-recovery logic. Now ordering is: guards → plan check → - claim → set_plan → start. + Cheap to allocate; one per verb invocation keeps the runner + stateless across requests. """ - if guard := await self._run_claim_guards(agent_id=agent_id, task=t): - return self._with_briefing(guard, briefing), t - # Plan precondition BEFORE any state mutation. Atomic invariant. + return VerbRunner(task_service=self.task, git_service=self.git) + + async def _resume_from_claimed( + self, + ctx: _ClaimPlanStartContext, + ) -> Envelope: + """Recover from a stuck `claimed` state owned by the same agent. + + spec's composed `claim` action's source-statuses do not include + CLAIMED, so the spec gate would reject re-claiming an + already-owned task. This branch keeps the spec contract intact + (we never call `claim` again) but lets the agent recover by + running just set_plan (if a plan was supplied or stored) and + start. Shared between ``i_will_work_on`` and ``i_will_plan`` — + ``ctx.verb_name`` selects the verb-specific labels / next_hint. + """ + agent_id = ctx.agent_id + task_id = ctx.task_id + t = ctx.task + briefing = ctx.briefing + role_str = ctx.role_str + plan = ctx.plan + verb_name = ctx.verb_name if not t.plan and not plan: - return Envelope.tracing_gap( - missing=["plan"], - remediate=( - f"call i_will_work_on(task_id='{task_id}'," - f" plan='')" - ), - context_briefing=briefing, - ), t - # claim() transitions pending → claimed; idempotent for same assignee. - # Branch creation runs inside _finalize_claim and rolls back on - # failure (audit P0-7 / S-01); we surface the failure as an envelope - # so the agent gets remediate instead of a 500. - try: - t = await self.task.claim(task_id, agent_id) - except Exception as exc: - return Envelope.invalid_state( - message=f"claim failed during finalization: {exc}", - remediate=( - "branch or workspace setup failed; the claim was rolled" - " back. Check workspace + token, then retry" - " i_will_work_on(task_id, plan)." - ), - context_briefing=briefing, - ), None - if t is None: - return Envelope.invalid_state( - message="claim failed", - remediate="task may already be claimed by another agent", - context_briefing=briefing, - ), t - if plan: - t = await self.task.set_plan(task_id, plan) - t = await self.task.start(task_id, agent_id) - if t is None: - return self._start_failed_envelope(task_id, briefing), t - return None, t - - @staticmethod - def _start_failed_envelope(task_id: UUID, briefing: dict[str, Any]) -> Envelope: - """Rejection envelope when ``task.start()`` returns None. - - ``start()`` returns None on invalid status, ownership mismatch, or - missing plan. Surface the failure rather than fall through to an - OK envelope that dereferences ``None.status``. - """ - return Envelope.invalid_state( - message=f"start failed for task {task_id}", - remediate=( - "task not in a startable state" - " (claimed/paused/needs_revision) or no plan recorded" - ), - context_briefing=briefing, - ) - - async def _i_will_work_on_needs_revision( - self, - agent_id: UUID, - task_id: UUID, - t: Any, - briefing: dict[str, Any], - ) -> tuple[Envelope | None, Any]: - """needs_revision branch for i_will_work_on. Returns (rejection|None, task).""" - if t.assigned_to != agent_id: - t = await self.task.claim(task_id, agent_id) - if t is None: - return Envelope.invalid_state( - message="claim failed", - remediate="task may already be claimed by another agent", + return await self._emit_rejection( + Envelope.tracing_gap( + missing=["plan"], + remediate=( + f"call {verb_name}(task_id='{task_id}'," + f" plan='')" + ), context_briefing=briefing, - ), None - t = await self.task.start(task_id, agent_id) - if t is None: - return self._start_failed_envelope(task_id, briefing), None - return None, t - - async def _i_will_work_on_claimed( - self, - agent_id: UUID, - task_id: UUID, - t: Any, - plan: str | None, - briefing: dict[str, Any], - ) -> tuple[Envelope | None, Any]: - """claimed branch for i_will_work_on. Returns (rejection|None, task). - - Recovery path (Bug A from 2026-05-09 smoke): if the task is in - `claimed` without a plan (e.g. orchestrator restart, prior - partial-claim race) and the caller now supplies one, set it - before start() instead of failing with "no plan recorded". If - the task still has no plan and none is supplied, surface the - same tracing_gap shape `_i_will_work_on_pending` uses. - """ - guard = await self._run_claim_guards( - agent_id=agent_id, task=t, skip_sequence=True - ) - if guard: - return self._with_briefing(guard, briefing), None - if not t.plan and not plan: - return Envelope.tracing_gap( - missing=["plan"], - remediate=( - f"call i_will_work_on(task_id='{task_id}'," - f" plan='')" + ).with_introspection(task=t, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb=verb_name, + ) + # Concurrency guards still apply (paused / already-active in another + # task). Sibling sequence is skipped on resumption — see + # _run_claim_guards docstring. + if guard := await self._run_claim_guards( + agent_id=agent_id, + task=t, + skip_sequence=True, + ): + return await self._emit_rejection( + self._with_briefing(guard, briefing).with_introspection( + task=t, role=role_str ), - context_briefing=briefing, - ), None - if plan and not t.plan: - t = await self.task.set_plan(task_id, plan) - t = await self.task.start(task_id, agent_id) + agent_id=agent_id, + task_id=task_id, + verb=verb_name, + ) + try: + if plan and not t.plan: + t = await self.task.set_plan(task_id, plan) + t = await self.task.start(task_id, agent_id) + except Exception as exc: + return await self._emit_rejection( + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb=verb_name, + ) if t is None: - return self._start_failed_envelope(task_id, briefing), None - return None, t + return await self._emit_rejection( + Envelope.invalid_state( + message=f"start failed for task {task_id}", + remediate=( + "task not in a startable state" + " (claimed/paused/needs_revision) or no plan recorded" + ), + context_briefing=briefing, + ).with_introspection(task=None, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb=verb_name, + ) + await self._touch(task_id) + return Envelope.ok( + status=str(t.status), + task_id=str(task_id), + next=spec_module._INTENT_VERBS[verb_name].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + + async def _claim_plan_start_gate( + self, + ctx: _ClaimPlanStartContext, + role: spec_module.Role, + spec_ctx: spec_module.Context, + ) -> Envelope | None: + """Run all gates for an ``i_will_work_on`` / ``i_will_plan`` call. + + Order: spec.can_invoke_intent -> behavioral claim guards + (already_active / paused / sibling_sequence). Any rejection + short-circuits with the appropriate envelope. + + Per-role claim authority (CLAIM_RULES) is enforced inside + spec.can_invoke_action when action == "claim", called by + can_invoke_intent, so no separate spec.can_claim call is needed. + """ + t, briefing, role_str = ctx.task, ctx.briefing, ctx.role_str + verb_name = ctx.verb_name + decision = spec_module.can_invoke_intent(role, verb_name, t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), + agent_id=ctx.agent_id, + task_id=ctx.task_id, + verb=verb_name, + ) + # Behavioral pre-flight guards the spec doesn't yet model: + # - already_active: agent has another in_progress task elsewhere + # - paused_tasks: agent has a paused task they should resume first + # - sibling_sequence: an earlier-numbered sibling is still open + # The role/state/task_type checks already passed via the spec gate + # above. These migrate into spec.extra_preconditions in a later + # task; until then, keep them imperative so concurrency invariants + # stay enforced. + if guard := await self._run_claim_guards( + agent_id=ctx.agent_id, + task=t, + ): + return await self._emit_rejection( + self._with_briefing(guard, briefing).with_introspection( + task=t, role=role_str + ), + agent_id=ctx.agent_id, + task_id=ctx.task_id, + verb=verb_name, + ) + return None + + async def _claim_plan_start_run( + self, ctx: _ClaimPlanStartContext, agent: Any, spec_ctx: spec_module.Context + ) -> Envelope: + """Execute composed (claim, set_plan, start) via the verb runner. + + Caller has already validated all gates. Translates runner + exceptions and ``None`` returns into invalid_state envelopes + so the agent gets a remediation instead of a 500. Shared + between ``i_will_work_on`` and ``i_will_plan``. + """ + t, briefing, role_str = ctx.task, ctx.briefing, ctx.role_str + verb_name = ctx.verb_name + runner = self._verb_runner() + try: + t = await runner.run_intent(verb_name, t, agent, spec_ctx) + except Exception as exc: + return await self._emit_rejection( + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=ctx.agent_id, + task_id=ctx.task_id, + verb=verb_name, + ) + if t is None: + # A composed atomic action returned None (e.g. start() rejected + # because of an ownership/state mismatch the spec gate could not + # see). Surface as invalid_state so the agent gets a remediation + # rather than a 500. + return await self._emit_rejection( + Envelope.invalid_state( + message=f"start failed for task {ctx.task_id}", + remediate=( + "task not in a startable state" + " (claimed/paused/needs_revision) or no plan recorded" + ), + context_briefing=briefing, + ).with_introspection(task=None, role=role_str), + agent_id=ctx.agent_id, + task_id=ctx.task_id, + verb=verb_name, + ) + await self._touch(ctx.task_id) + return Envelope.ok( + status=str(t.status), + task_id=str(ctx.task_id), + next=spec_module._INTENT_VERBS[verb_name].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) async def i_will_work_on( self, agent_id: UUID, task_id: UUID, plan: str | None = None ) -> Envelope: - """Claim/start/recover any actionable state of agent_id's task_id.""" + """Claim a task and start work on it. + + Atomic: spec.can_invoke_intent runs before any state mutation; + the composed (claim, set_plan, start) sequence is wrapped in a + savepoint by the runner so a mid-sequence failure rolls back + the DB. Idempotent re-entry: a respawned dev re-calling on a + task they already own in_progress just refreshes the heartbeat. + """ t = await self.task.get(task_id) if t is None: return await self._emit_rejection( @@ -506,56 +654,66 @@ class Choreographer: verb="i_will_work_on", ) agent = await self.task.agent_for(agent_id) - role = str(agent.role) if agent is not None else "developer" - status = str(t.status) + role_str = str(agent.role) if agent is not None else "developer" briefing = await self._briefing_for(agent_id, task_id) - - rejection: Envelope | None = None - if status == "needs_revision": - rejection, t = await self._i_will_work_on_needs_revision( - agent_id, task_id, t, briefing - ) - elif status == "pending": - rejection, t = await self._i_will_work_on_pending( - agent_id, task_id, t, plan, briefing - ) - elif status == "claimed" and t.assigned_to == agent_id: - rejection, t = await self._i_will_work_on_claimed( - agent_id, task_id, t, plan, briefing - ) - elif status == "in_progress" and t.assigned_to == agent_id: - # Idempotent re-entry: respawned dev re-calling i_will_work_on - # on a task they already own in_progress. Skip start() (would - # reject — wrong source state) but fall through to the OK - # envelope at the end. Heartbeat fires there too, refreshing - # reaper activity. - pass - else: - rejection = Envelope.invalid_state( - message=f"task {task_id} is in {status}; cannot start work", - remediate="call give_me_work() to find an actionable task", - context_briefing=briefing, - ) - - if rejection is not None: - rejection.with_introspection(task=t, role=role) + try: + role = spec_module.Role(role_str) + except ValueError: return await self._emit_rejection( - rejection, + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), agent_id=agent_id, task_id=task_id, verb="i_will_work_on", ) - - await self._touch(task_id) - return Envelope.ok( - status=str(t.status), - task_id=str(task_id), - next=( - "edit + commit(message) for each meaningful change," - " then open_pr(task_id) and i_am_done(task_id)" - ), - context_briefing=briefing, - ).with_introspection(task=t, role=role) + spec_ctx = spec_module.Context( + plan=plan, + actor_id=agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + ) + ctx = _ClaimPlanStartContext( + agent_id=agent_id, + task_id=task_id, + task=t, + role_str=role_str, + briefing=briefing, + plan=plan, + verb_name="i_will_work_on", + ) + # Idempotent re-entry: agent already owns the task in_progress. + # Touch heartbeat and short-circuit before the spec gate (which + # would otherwise reject because in_progress is not a source state + # for the composed `claim` action). + if str(t.status) == "in_progress" and t.assigned_to == agent_id: + await self._touch(task_id) + return Envelope.ok( + status=str(t.status), + task_id=str(task_id), + next=spec_module._INTENT_VERBS["i_will_work_on"].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + # Recovery re-entry: task stuck in `claimed` (e.g. orchestrator restart + # or a partial-claim race) and the agent already owns it. The spec + # `claim` action's source-statuses do NOT include CLAIMED, so the spec + # gate would reject. Surface this as a runner call that runs only + # set_plan + start. Without this block, an agent reclaiming from a + # crashed mid-sequence would loop forever (Bug A from the 2026-05-09 + # smoke test). + if str(t.status) == "claimed" and t.assigned_to == agent_id: + envelope = await self._resume_from_claimed(ctx) + return await self._post_claim_journal_gate( + "i_will_work_on", agent_id, task_id, envelope + ) + if rejection := await self._claim_plan_start_gate(ctx, role, spec_ctx): + return rejection + envelope = await self._claim_plan_start_run(ctx, agent, spec_ctx) + return await self._post_claim_journal_gate( + "i_will_work_on", agent_id, task_id, envelope + ) @staticmethod def _with_briefing(env: Envelope, briefing: dict[str, Any]) -> Envelope: @@ -566,8 +724,9 @@ class Choreographer: async def open_pr(self, agent_id: UUID, task_id: UUID) -> Envelope: """Push the dev's branch and open a PR. - Atomic: validates ALL preconditions (assignee, commits, - no-prior-PR) BEFORE running any git side effects. If any check + Atomic: spec.can_invoke_intent runs first and enforces ALL + preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS, + PRECONDITION_NO_PR) BEFORE any git side effect. If any check fails, no PR is opened. After success, the dev calls ``i_am_done`` to actually transition the task to awaiting_qa. @@ -577,8 +736,10 @@ class Choreographer: handoff, then never called i_am_done — orphaning PRs (e.g. PR #12 in the smoke-test trace). - Idempotent on re-call: if a PR is already open, returns OK - pointing the dev at ``i_am_done`` without opening another. + Idempotent on re-call: if the caller already owns the task and + a PR is already open, return OK pointing at ``i_am_done`` rather + than the spec's ``tracing_gap`` for ``no_prior_pr``. Two calls + in a row should not surface a misleading "open a PR" hint. """ t = await self.task.get(task_id) if t is None: @@ -590,33 +751,12 @@ class Choreographer: ) briefing = await self._briefing_for(agent_id, task_id) agent = await self.task.agent_for(agent_id) - role = str(agent.role) if agent is not None else "developer" - if t.assigned_to != agent_id: - return await self._emit_rejection( - Envelope.not_authorized( - message=f"task {task_id} is not assigned to you", - remediate="call give_me_work() to find your work", - context_briefing=briefing, - ).with_introspection(task=t, role=role), - agent_id=agent_id, - task_id=task_id, - verb="open_pr", - ) - if not t.commits: - return await self._emit_rejection( - Envelope.invalid_state( - message="no commits on this task yet", - remediate=( - "commit at least one change before submitting for QA — " - "call commit(message='')" - ), - context_briefing=briefing, - ).with_introspection(task=t, role=role), - agent_id=agent_id, - task_id=task_id, - verb="open_pr", - ) - if t.pr_number is not None: + role_str = str(agent.role) if agent is not None else "developer" + # Idempotent re-entry: caller owns the task and a PR is already + # open. The spec would otherwise reject with PRECONDITION_NO_PR + # tracing_gap, but agents calling open_pr twice should get the + # existing PR's i_am_done hint, not a misleading "open a PR" remediate. + if t.pr_number is not None and t.assigned_to == agent_id: return Envelope.ok( status=str(t.status), task_id=str(task_id), @@ -625,34 +765,86 @@ class Choreographer: f"i_am_done(task_id, notes='...') when self-verified" ), context_briefing=briefing, - ).with_introspection(task=t, role=role) - + ).with_introspection(task=t, role=role_str) + try: + role = spec_module.Role(role_str) + except ValueError: + return await self._emit_rejection( + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb="open_pr", + ) + spec_ctx = spec_module.Context( + actor_id=agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + ) + decision = spec_module.can_invoke_intent(role, "open_pr", t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), + agent_id=agent_id, + task_id=task_id, + verb="open_pr", + ) await self._touch(task_id) - await self.git.push_branch(t.branch_name) - parent = parent_branch_for(t.branch_name) - pr = await self.git.create_pr(t.branch_name, parent=parent, is_root_pr=False) - + runner = self._verb_runner() + try: + await runner.run_intent("open_pr", t, agent, spec_ctx) + except Exception as exc: + return await self._emit_rejection( + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb="open_pr", + ) + # Re-fetch: git_service.create_pr writes pr_number / pr_url onto + # the task row. The runner doesn't bubble that update back, so a + # fresh load is the simplest way to surface the new fields in the + # OK envelope's next-hint and introspection block. + refreshed = await self.task.get(task_id) + t = refreshed if refreshed is not None else t return Envelope.ok( status=str(t.status), task_id=str(task_id), - next=( - f"PR #{pr['pr_number']} opened; call " - f"i_am_done(task_id, notes='...') when self-verified" - ), + next=spec_module._INTENT_VERBS["open_pr"].next_hint(t), context_briefing=briefing, - ).with_introspection(task=t, role=role) + ).with_introspection(task=t, role=role_str) async def i_am_done(self, agent_id: UUID, task_id: UUID, notes: str) -> Envelope: """Submit work for QA. - Preconditions enforced by gates: - - tracing: progress entry, journal:reflect, acceptance criteria - - field-level: at least one commit, PR open - The dev must have called ``commit()`` (do_server) at least once and - ``open_pr(task_id)`` to push + open the PR. Calling i_am_done - is the dev's explicit attestation that the work is complete; it - auto-runs the in_progress → verifying transition (which seeds - ``self_verified``) and then verifying → awaiting_qa. + Atomic: ``spec.can_invoke_intent`` runs first and enforces the + intent's role membership and the ``PRECONDITION_OWNERSHIP`` / + ``PRECONDITION_COMMITS`` extra preconditions BEFORE any state + mutation. After the spec gate accepts, two additional gate sets + run as defense-in-depth (the spec doesn't yet model them): + + - tracing-gate preconditions (progress entry, journal:reflect, + acceptance criteria addressed) + - field-level submit-qa gates (currently: PR open; commits and + ownership are already covered by the spec extras above) + + Once all gates pass, ``VerbRunner.run_intent("i_am_done", ...)`` + dispatches the (submit_verification, submit_qa) atomic chain + wrapped in a savepoint so a mid-sequence failure rolls back the + DB. Recovery re-entry: a task already in ``verifying`` owned by + the caller has its first composed action (submit_verification, + source IN_PROGRESS) rejected by the spec gate. We short-circuit + before the spec gate and run only ``submit_qa`` — the spec + doesn't model partial-progress recovery, so that branch lives + in the verb body. The previous strict path required a separate ``submit_for_verification`` verb that wasn't on any manifest, making i_am_done unreachable @@ -668,66 +860,325 @@ class Choreographer: verb="i_am_done", ) agent = await self.task.agent_for(agent_id) - role = str(agent.role) if agent is not None else "developer" - if t.assigned_to != agent_id: - return await self._emit_rejection( + role_str = str(agent.role) if agent is not None else "developer" + briefing = await self._briefing_for(agent_id, task_id) + ctx = _IAmDoneContext( + agent_id=agent_id, + task_id=task_id, + task=t, + role_str=role_str, + briefing=briefing, + notes=notes, + ) + try: + role = spec_module.Role(role_str) + except ValueError: + return await self._reject_i_am_done( + ctx, Envelope.not_authorized( - message="not assigned to you", - remediate="claim it via i_will_work_on(task_id) first", - context_briefing=await self._briefing_for(agent_id, task_id), - ).with_introspection(task=t, role=role), - agent_id=agent_id, - task_id=task_id, - verb="i_am_done", + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ), ) - - # 1. Tracing-gate preconditions (progress / reflect / acceptance) - if rejection := await self._check_tracing_gates(agent_id, task_id, t): - rejection.with_introspection(task=t, role=role) - return await self._emit_rejection( - rejection, agent_id=agent_id, task_id=task_id, verb="i_am_done" + spec_ctx = spec_module.Context( + actor_id=agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + notes=notes, + ) + # Recovery re-entry: task already in `verifying` owned by the caller + # (e.g. orchestrator restart between submit_verification and + # submit_qa). The spec gate would reject because the first composed + # action `submit_verification` requires source IN_PROGRESS. Run only + # submit_qa via the runner-equivalent path, then continue with the + # standard tracing/field gates beforehand. + if str(t.status) == "verifying" and t.assigned_to == agent_id: + return await self._i_am_done_resume_from_verifying(ctx) + decision = spec_module.can_invoke_intent(role, "i_am_done", t, spec_ctx) + if not decision.allowed: + return await self._reject_i_am_done( + ctx, Envelope.from_decision(decision, briefing=briefing) ) + if gate_rejection := await self._i_am_done_gate(ctx): + return gate_rejection + return await self._i_am_done_run(ctx, agent, spec_ctx) - # 2. Field-level gates (Gate Set E) — commits + PR (self_verified - # auto-set in step 3, so it's not a precondition the dev must satisfy). - if rejection := await self._check_submit_qa_field_gates(agent_id, task_id, t): - rejection.with_introspection(task=t, role=role) - return await self._emit_rejection( - rejection, agent_id=agent_id, task_id=task_id, verb="i_am_done" + async def _reject_i_am_done(self, ctx: _IAmDoneContext, env: Envelope) -> Envelope: + """Stamp introspection + emit audit row for an i_am_done rejection.""" + env.with_introspection(task=ctx.task, role=ctx.role_str) + return await self._emit_rejection( + env, agent_id=ctx.agent_id, task_id=ctx.task_id, verb="i_am_done" + ) + + async def _i_am_done_gate(self, ctx: _IAmDoneContext) -> Envelope | None: + """Run defense-in-depth tracing + field-level gates the spec doesn't model. + + Returns the rejection envelope if any gate fails; None on pass. + """ + if rejection := await self._check_tracing_gates( + ctx.agent_id, ctx.task_id, ctx.task + ): + return await self._reject_i_am_done(ctx, rejection) + if rejection := await self._check_submit_qa_field_gates( + ctx.agent_id, ctx.task_id, ctx.task + ): + return await self._reject_i_am_done(ctx, rejection) + return None + + async def _i_am_done_run( + self, ctx: _IAmDoneContext, agent: Any, spec_ctx: spec_module.Context + ) -> Envelope: + """Dispatch the spec-composed (submit_verification, submit_qa) chain.""" + runner = self._verb_runner() + try: + t = await runner.run_intent("i_am_done", ctx.task, agent, spec_ctx) + except Exception as exc: + return await self._reject_i_am_done( + ctx, + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=ctx.briefing, + ), ) + await self._notify_qa(ctx.agent_id, ctx.task_id, t) + await self._touch(ctx.task_id) + return await self._build_i_am_done_ok(ctx.agent_id, ctx.task_id, t) - # 3. Auto-run in_progress → verifying (sets self_verified) if needed. - if str(t.status) == "in_progress": - verified = await self.task.submit_verification(agent_id, task_id, notes) - if verified is not None: - t = verified + async def _i_am_done_resume_from_verifying(self, ctx: _IAmDoneContext) -> Envelope: + """Recovery path: task is already in `verifying` owned by caller. - # 4. Submit verifying → awaiting_qa. - submitted = await self.task.submit_qa(agent_id, task_id, notes) - if submitted is not None: - t = submitted - await self._notify_qa(agent_id, task_id, t) - await self._touch(task_id) - return await self._build_i_am_done_ok(agent_id, task_id, t) + The spec's i_am_done composes (submit_verification, submit_qa) and + the runner dispatches the FIRST action; submit_verification's + source_status is IN_PROGRESS so a `verifying` task hits invalid_state + through the spec gate. Run submit_qa directly, plus the same tracing + + field-level gates the standard path enforces. + """ + if gate_rejection := await self._i_am_done_gate(ctx): + return gate_rejection + try: + submitted = await self.task.submit_qa(ctx.agent_id, ctx.task_id, ctx.notes) + except Exception as exc: + return await self._reject_i_am_done( + ctx, + Envelope.invalid_state( + message=f"submit_qa failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=ctx.briefing, + ), + ) + t = submitted if submitted is not None else ctx.task + await self._notify_qa(ctx.agent_id, ctx.task_id, t) + await self._touch(ctx.task_id) + return await self._build_i_am_done_ok(ctx.agent_id, ctx.task_id, t) async def _check_tracing_gates( self, agent_id: UUID, task_id: UUID, t: Any ) -> Envelope | None: - """Run progress / reflect / acceptance-criteria tracing gates.""" + """Run progress / reflect / acceptance-criteria / during-work tracing gates. + + The spec composes (submit_verification, submit_qa) for i_am_done and + the auto-run submit_verification flips self_verified=True before + submit_qa runs. SELF_VERIFIED therefore acts as a defense-in-depth + backstop *after* the spec — checking it pre-flight would block the + auto-verify path. It is filtered here and re-asserted by the spec + action's own preconditions. + """ + from roboco.foundation.policy import tracing as _tr + has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id) - gate_ctx = GateContext(journal_reflect_present=has_reflect) - gate = check_requirements( - t, - [ - Requirement.PROGRESS_AT_LEAST_ONE, - Requirement.JOURNAL_REFLECT, - Requirement.ACCEPTANCE_CRITERIA_ADDRESSED, - ], - gate_ctx, + has_decision = await self.journal.has_decision_for_task(agent_id, task_id) + has_learning = await self.journal.has_learning_for_task(agent_id, task_id) + has_struggle = await self.journal.has_struggle_for_task(agent_id, task_id) + during_work_count = sum([has_decision, has_learning, has_struggle]) + + ctx = _tr.GateContext( + journal_reflect_present=has_reflect, + journal_decision_present=has_decision, + journal_learning_present=has_learning, + journal_struggle_present=has_struggle, + journal_during_work_count=during_work_count, ) - if gate.passed: + requirements: list[_tr.Requirement] = [ + r + for r in _tr.requirements_for("i_am_done") + if r is not _tr.Requirement.SELF_VERIFIED + ] + result = _tr.check_requirements( + task=t, + requirements=requirements, + ctx=ctx, + ) + if result.passed: return None - return await self._build_tracing_gap(agent_id, task_id, gate.missing) + return await self._build_tracing_gap(agent_id, task_id, result.missing) + + async def _post_claim_journal_gate( + self, + verb: str, + agent_id: UUID, + task_id: UUID, + envelope: Envelope, + ) -> Envelope: + """Apply the claim-time journal tracing gate AFTER a successful claim. + + Pre-gateway parity (spec §11 P1, P3): the (claim, set_plan, start) + sequence is allowed to commit so the agent owns the task, then we + verify the matching journal entry exists. If absent, the agent + gets a tracing_gap with a remediation hint — they journal and + retry the verb (idempotent re-entry shortcuts back to OK once the + entry is present). + + If `envelope` is already an error (claim failed or the runner + rejected), we pass it through untouched — no point demanding a + journal note when the claim itself didn't stick. + """ + if envelope.error is not None: + return envelope + t = await self.task.get(task_id) + if t is None: + return envelope + gap = await self._check_claim_journal_at_claim(verb, agent_id, task_id, t) + return gap if gap is not None else envelope + + async def _check_claim_journal_at_claim( + self, verb: str, agent_id: UUID, task_id: UUID, t: Any + ) -> Envelope | None: + """Post-claim tracing gate for i_will_work_on / i_will_plan. + + Pre-gateway parity (spec §11 P1, P3): developers wrote a + journal:note on every claim; PMs wrote a journal:decision on + plan. The check runs AFTER the composed (claim, set_plan, start) + sequence has succeeded — the claim itself stays. If the journal + entry is missing, the agent receives a tracing_gap envelope and + must journal then retry the verb (similar to how i_am_done's + post-claim gates work). + + ``PLAN`` is filtered out of the required-set because the spec's + composed action has already enforced PRECONDITION_PLAN before + reaching this point — re-asserting it here would be redundant + and produce a misleading hint when the only real failure is the + missing journal entry. + """ + from roboco.foundation.policy import tracing as _tr + + ctx = _tr.GateContext() + if verb == "i_will_work_on": + has_note = await self.journal.has_note_for_task(agent_id, task_id) + ctx = _tr.GateContext(journal_note_at_claim_present=has_note) + elif verb == "i_will_plan": + has_decision = await self.journal.has_decision_for_task(agent_id, task_id) + ctx = _tr.GateContext(journal_decision_present=has_decision) + requirements: list[_tr.Requirement] = [ + r for r in _tr.requirements_for(verb) if r is not _tr.Requirement.PLAN + ] + result = _tr.check_requirements( + task=t, + requirements=requirements, + ctx=ctx, + ) + if result.passed: + return None + return await self._build_tracing_gap(agent_id, task_id, result.missing) + + async def _check_pm_decision_required( + self, verb: str, agent_id: UUID, task_id: UUID, t: Any + ) -> Envelope | None: + """Standard PM-verb tracing gate driven by VERB_REQUIREMENTS. + + Used by ``unblock``, ``escalate_up``, ``escalate_to_ceo``, and + ``delegate`` — each declares only ``JOURNAL_DECISION`` in the + foundation table. Verbs requiring more (``complete``, + ``submit_up``) use the verb-specific helpers below which thread + the additional state (reflect, notes, subtasks) into GateContext. + """ + from roboco.foundation.policy import tracing as _tr + + has_decision = await self.journal.has_decision_for_task(agent_id, task_id) + ctx = _tr.GateContext(journal_decision_present=has_decision) + result = _tr.check_requirements( + task=t, + requirements=list(_tr.requirements_for(verb)), + ctx=ctx, + ) + if result.passed: + return None + return await self._build_tracing_gap(agent_id, task_id, result.missing) + + async def _check_complete_gates( + self, agent_id: UUID, task_id: UUID, notes: str + ) -> Envelope | None: + """Tracing gate for cell-PM and main-PM ``complete`` verbs. + + VERB_REQUIREMENTS["complete"] = JOURNAL_DECISION + JOURNAL_REFLECT + + NOTES_MIN_CHARS. ``SUBTASKS_TERMINAL`` is enforced separately by + ``_subtasks_not_terminal_envelope`` in the cell/main complete + guards because that gate emits a richer remediation message + listing the non-terminal subtasks; keeping it inline preserves + that UX. + """ + from types import SimpleNamespace + + from roboco.config import settings as _settings + from roboco.foundation.policy import tracing as _tr + + has_decision = await self.journal.has_decision_for_task(agent_id, task_id) + has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id) + task_view = SimpleNamespace(notes=notes) + ctx = _tr.GateContext( + journal_decision_present=has_decision, + journal_reflect_present=has_reflect, + notes_min_chars=getattr(_settings, "notes_min_chars", 20), + ) + result = _tr.check_requirements( + task=task_view, + requirements=list(_tr.requirements_for("complete")), + ctx=ctx, + ) + if result.passed: + return None + return await self._build_tracing_gap(agent_id, task_id, result.missing) + + async def _check_submit_up_gates( + self, agent_id: UUID, task_id: UUID, notes: str + ) -> Envelope | None: + """Tracing gate for ``submit_up`` (cell PM bubble-up). + + VERB_REQUIREMENTS["submit_up"] = SUBTASKS_TERMINAL + JOURNAL_DECISION + + JOURNAL_REFLECT + NOTES_MIN_CHARS. The notes value is threaded + through a SimpleNamespace shim because the verb hasn't persisted + it to the task yet. ``SUBTASKS_TERMINAL`` is filtered out here + because the inline ``_subtasks_not_terminal_envelope`` that + follows enumerates the non-terminal subtask ids — strictly richer + remediation than the generic foundation hint. + """ + from types import SimpleNamespace + + from roboco.config import settings as _settings + from roboco.foundation.policy import tracing as _tr + + has_decision = await self.journal.has_decision_for_task(agent_id, task_id) + has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id) + task_view = SimpleNamespace(notes=notes) + ctx = _tr.GateContext( + journal_decision_present=has_decision, + journal_reflect_present=has_reflect, + notes_min_chars=getattr(_settings, "notes_min_chars", 20), + ) + requirements: list[_tr.Requirement] = [ + r + for r in _tr.requirements_for("submit_up") + if r is not _tr.Requirement.SUBTASKS_TERMINAL + ] + result = _tr.check_requirements( + task=task_view, + requirements=requirements, + ctx=ctx, + ) + if result.passed: + return None + return await self._build_tracing_gap(agent_id, task_id, result.missing) async def _check_submit_qa_field_gates( self, agent_id: UUID, task_id: UUID, t: Any @@ -788,6 +1239,54 @@ class Choreographer: context_briefing=await self._briefing_for(agent_id, task_id), ).with_introspection(task=t, role=role) + @staticmethod + def _hint_for_missing_key(missing_key: str, task_id: UUID) -> str | None: + """Map a single tracing-requirement key to its agent-facing hint. + + Returns ``None`` for keys that need composite handling (i.e., + ``acceptance_criterion:``, which the caller batches into + a single multi-criterion hint). + """ + from roboco.config import settings as _roboco_settings + + tid = str(task_id) + notes_min = getattr(_roboco_settings, "notes_min_chars", 20) + simple_hints: dict[str, str] = { + "progress>=1": hint_for_missing_progress(), + "journal:reflect": hint_for_missing_reflect(task_id=tid), + "journal:decision": hint_for_missing_journal_decision(), + "qa_notes>=min": hint_for_missing_qa_notes(), + "journal:learning": hint_for_missing_journal_learning(), + "qa_evidence_inspected": hint_for_evidence_not_inspected(task_id=tid), + "docs_notes>=min": hint_for_short_doc_notes( + min_chars=_roboco_settings.docs_notes_min_chars + ), + "docs_files_non_empty": hint_for_missing_doc_files(), + "journal:note_at_claim": ( + "pre-gateway parity P1: write a journal:note at claim. " + f"Call note(scope='note', task_id='{tid}', " + "text='') describing your read of the " + "task, then retry i_will_work_on." + ), + "journal:decision_at_claim": ( + "pre-gateway parity P3: PMs write a journal:decision on plan. " + f"Call note(scope='decision', task_id='{tid}', " + "text='') with your planning rationale, " + "then retry i_will_plan." + ), + "notes>=min": ( + f"`notes` must be at least {notes_min} chars describing the " + "merge / escalation rationale; pass a longer notes argument " + "and retry." + ), + "subtasks_terminal": ( + "all subtasks must be in a terminal state (completed or " + "cancelled) before this transition; wait for the closure " + "dispatcher to bring you back when ready." + ), + } + return simple_hints.get(missing_key) + async def _build_tracing_gap( self, agent_id: UUID, task_id: UUID, missing: list[str] ) -> Envelope: @@ -795,12 +1294,12 @@ class Choreographer: hints: list[str] = [] unaddressed: list[str] = [] for m in missing: - if m == "progress>=1": - hints.append(hint_for_missing_progress()) - elif m == "journal:reflect": - hints.append(hint_for_missing_reflect(task_id=str(task_id))) - elif m.startswith("acceptance_criterion:"): + if m.startswith("acceptance_criterion:"): unaddressed.append(m.split(":", 1)[1]) + continue + hint = self._hint_for_missing_key(m, task_id) + if hint is not None: + hints.append(hint) if unaddressed: hints.append( hint_for_unaddressed_acceptance_criteria( @@ -862,7 +1361,18 @@ class Choreographer: async def i_am_blocked( self, agent_id: UUID, task_id: UUID, reason: str ) -> Envelope: - """Escalate task_id and write a struggle journal entry; idle the agent.""" + """Escalate task_id and write a struggle journal entry; idle the agent. + + Atomic: ``spec.can_invoke_intent`` runs first and enforces role + membership (developer/qa/documenter) and the source-status + constraint of the composed ``block`` action (in_progress only). + After the spec gate accepts, the journal:struggle entry is + written from the verb body — the runner does NOT model journal + side effects, and a struggle log is a side effect outside the + lifecycle action. Then ``VerbRunner.run_intent("i_am_blocked", + ...)`` dispatches the (block,) atomic chain wrapped in a + savepoint so a mid-sequence failure rolls back the DB. + """ t = await self.task.get(task_id) if t is None: return await self._emit_rejection( @@ -872,18 +1382,66 @@ class Choreographer: verb="i_am_blocked", ) agent = await self.task.agent_for(agent_id) - role = str(agent.role) if agent is not None else "developer" + role_str = str(agent.role) if agent is not None else "developer" + briefing = await self._briefing_for(agent_id, task_id) + try: + role = spec_module.Role(role_str) + except ValueError: + return await self._emit_rejection( + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb="i_am_blocked", + ) + spec_ctx = spec_module.Context( + actor_id=agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + notes=reason, + ) + decision = spec_module.can_invoke_intent(role, "i_am_blocked", t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), + agent_id=agent_id, + task_id=task_id, + verb="i_am_blocked", + ) + # Journal:struggle is a side effect outside the lifecycle action; + # the runner does NOT model journal effects, so it stays in the + # verb body. Written before the runner dispatches `block` so a + # later runner failure still leaves an audit trail of the agent's + # struggle. await self.journal.write_struggle( agent_id=agent_id, task_id=task_id, content=reason ) - t = await self.task.escalate(agent_id, task_id, reason) + runner = self._verb_runner() + try: + t = await runner.run_intent("i_am_blocked", t, agent, spec_ctx) + except Exception as exc: + return await self._emit_rejection( + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb="i_am_blocked", + ) await self._touch(task_id) return Envelope.ok( status=str(t.status), task_id=str(task_id), - next="idle — PM will resolve and notify", - context_briefing=await self._briefing_for(agent_id, task_id), - ).with_introspection(task=t, role=role) + next=spec_module._INTENT_VERBS["i_am_blocked"].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) async def unclaim(self, agent_id: UUID, task_id: UUID) -> Envelope: """Voluntarily release a claimed/in_progress task back to pending. @@ -892,8 +1450,17 @@ class Choreographer: "or unclaim it first," but the verb didn't exist. This makes that promise true. The work-in-progress branch survives; only the claim is released so another agent (or the same one, fresh) can pick it - up. State and authorization checks live here; the DB write itself - is in ``TaskService.unclaim_for_agent``. + up. + + Spec gate runs first (role membership only — unclaim's IntentSpec + has ``composes=()``, so the spec does not enforce a source-status + constraint). After the gate accepts, the reassignment-rejection + branch (introduced in commit a5d358d) catches "task was reassigned + out from under you by an upstream verb" — the spec doesn't model + that case. Then the verb body owns dispatch via + ``task.unclaim_for_agent`` because ``composes=()`` (no atomic action + for the runner to run); the service-level None return surfaces as + invalid_state when the status drifted between get and write. """ t = await self.task.get(task_id) briefing = await self._briefing_for(agent_id, task_id) @@ -905,34 +1472,55 @@ class Choreographer: verb="unclaim", ) agent = await self.task.agent_for(agent_id) - role = str(agent.role) if agent is not None else "developer" - if t.assigned_to != agent_id: - # The task was reassigned out from under this agent — most - # commonly by an upstream verb that legitimately changed - # ownership (cell_pm_complete propagating to the parent, - # main_pm_complete clearing assigned_to to None when - # escalating to CEO, or a PM unblocking with restore=True). - # The agent's local state is stale; tell it concretely. - current_owner = ( - str(t.assigned_to) if t.assigned_to is not None else "" - ) + role_str = str(agent.role) if agent is not None else "developer" + try: + role = spec_module.Role(role_str) + except ValueError: return await self._emit_rejection( Envelope.not_authorized( - message=( - f"task {task_id} is no longer assigned to you " - f"(current owner: {current_owner})" - ), - remediate=( - "the task was reassigned by an upstream verb " - "(cell_pm_complete / main_pm_complete / unblock). " - "call give_me_work() to find your current work." - ), + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", context_briefing=briefing, - ).with_introspection(task=t, role=role), + ).with_introspection(task=t, role=role_str), agent_id=agent_id, task_id=task_id, verb="unclaim", ) + spec_ctx = spec_module.Context( + actor_id=agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + ) + decision = spec_module.can_invoke_intent(role, "unclaim", t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), + agent_id=agent_id, + task_id=task_id, + verb="unclaim", + ) + reassigned = self._reassigned_rejection( + _ReassignedCtx( + task=t, + agent_id=agent_id, + task_id=task_id, + role_str=role_str, + briefing=briefing, + upstream_hint=( + "the task was reassigned by an upstream verb " + "(cell_pm_complete / main_pm_complete / unblock). " + "call give_me_work() to find your current work." + ), + ) + ) + if reassigned is not None: + return await self._emit_rejection( + reassigned, agent_id=agent_id, task_id=task_id, verb="unclaim" + ) + # Verb body owns dispatch — unclaim's IntentSpec has composes=(), + # so VerbRunner has no atomic action to run. after = await self.task.unclaim_for_agent(task_id, agent_id) if after is None: return await self._emit_rejection( @@ -940,7 +1528,7 @@ class Choreographer: message=f"cannot unclaim from status {t.status}", remediate="only claimed/in_progress tasks can be unclaimed", context_briefing=briefing, - ).with_introspection(task=t, role=role), + ).with_introspection(task=t, role=role_str), agent_id=agent_id, task_id=task_id, verb="unclaim", @@ -951,9 +1539,9 @@ class Choreographer: return Envelope.ok( status=str(after.status), task_id=str(task_id), - next="task returned to pending; another agent (or you, fresh) can claim", + next=spec_module._INTENT_VERBS["unclaim"].next_hint(after), context_briefing=briefing, - ).with_introspection(task=after, role=role) + ).with_introspection(task=after, role=role_str) async def resume(self, agent_id: UUID, task_id: UUID) -> Envelope: """Resume a paused task this agent owns; transitions paused → in_progress. @@ -965,8 +1553,14 @@ class Choreographer: explicitly limited to needs_revision/pending/claimed; overloading it would muddy state-machine intent. ``resume`` keeps it explicit. - State and authorization checks live here; the DB write itself is - in ``TaskService.resume_for_agent``. + Spec gate runs first and enforces role membership plus the + composed ``resume`` action's source-status constraint (PAUSED + only). After the gate accepts, the reassignment-rejection branch + catches "task was reassigned by an upstream verb" — the spec + doesn't model that case, so the existing envelope text (preserved + from commit a5d358d) is the load-bearing hint. Then + ``VerbRunner.run_intent("resume", ...)`` dispatches the (resume,) + atomic chain wrapped in a savepoint. """ t = await self.task.get(task_id) briefing = await self._briefing_for(agent_id, task_id) @@ -978,39 +1572,70 @@ class Choreographer: verb="resume", ) agent = await self.task.agent_for(agent_id) - role = str(agent.role) if agent is not None else "developer" - if t.assigned_to != agent_id: - # See unclaim's matching branch for the rationale: the task - # was reassigned by an upstream verb. Surface the actual - # current owner so the agent can stop looping on a stale - # task_id and call give_me_work() instead. - current_owner = ( - str(t.assigned_to) if t.assigned_to is not None else "" - ) + role_str = str(agent.role) if agent is not None else "developer" + try: + role = spec_module.Role(role_str) + except ValueError: return await self._emit_rejection( Envelope.not_authorized( - message=( - f"task {task_id} is no longer assigned to you " - f"(current owner: {current_owner})" - ), - remediate=( - "the task was reassigned by an upstream verb. " - "call give_me_work() to find your current work." - ), + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", context_briefing=briefing, - ).with_introspection(task=t, role=role), + ).with_introspection(task=t, role=role_str), agent_id=agent_id, task_id=task_id, verb="resume", ) - after = await self.task.resume_for_agent(task_id, agent_id) + spec_ctx = spec_module.Context( + actor_id=agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + ) + decision = spec_module.can_invoke_intent(role, "resume", t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), + agent_id=agent_id, + task_id=task_id, + verb="resume", + ) + reassigned = self._reassigned_rejection( + _ReassignedCtx( + task=t, + agent_id=agent_id, + task_id=task_id, + role_str=role_str, + briefing=briefing, + upstream_hint=( + "the task was reassigned by an upstream verb. " + "call give_me_work() to find your current work." + ), + ) + ) + if reassigned is not None: + return await self._emit_rejection( + reassigned, agent_id=agent_id, task_id=task_id, verb="resume" + ) + runner = self._verb_runner() + runner_failure_msg: str | None = None + try: + after = await runner.run_intent("resume", t, agent, spec_ctx) + except Exception as exc: + after = None + runner_failure_msg = f"verb runner failed: {exc}" if after is None: + # Either the runner raised (recorded above) or the service + # returned None despite the spec gate accepting (race: status + # drifted between get and write). Both surface as invalid_state. return await self._emit_rejection( Envelope.invalid_state( - message=f"cannot resume from status {t.status}", + message=runner_failure_msg + or f"cannot resume from status {t.status} (drift)", remediate="only paused tasks can be resumed", context_briefing=briefing, - ).with_introspection(task=t, role=role), + ).with_introspection(task=t, role=role_str), agent_id=agent_id, task_id=task_id, verb="resume", @@ -1020,9 +1645,9 @@ class Choreographer: return Envelope.ok( status=str(after.status), task_id=str(task_id), - next="resumed; continue working — call commit() when ready", + next=spec_module._INTENT_VERBS["resume"].next_hint(after), context_briefing=briefing, - ).with_introspection(task=after, role=role) + ).with_introspection(task=after, role=role_str) async def i_am_idle(self, agent_id: UUID) -> Envelope: """Report no more work. Soft-block if there are unread A2As or @mentions. @@ -1132,83 +1757,17 @@ class Choreographer: # claim_doc_task + i_documented moved to ``doc.py`` (audit P2-2). - async def _i_will_plan_preflight( - self, pm_agent_id: UUID, task_id: UUID, t: Any, plan: str - ) -> Envelope | None: - """Run i_will_plan's role / status / plan / claim guards. None = pass. - - Idempotent on re-entry: if the caller already owns the task in - claimed/in_progress (their previous spawn moved it forward), the - verb returns OK with current state instead of rejecting. Without - this, a respawned PM hits 'task in in_progress, expected pending' - and loops until the reaper drops the claim back to pending — - producing the cycle smoke 2026-05-04 captured. - """ - agent = await self.task.agent_for(pm_agent_id) - role = str(agent.role) if agent is not None else "" - # Role-only gate: i_will_plan is principle-level reserved for PMs. - # The status check below ((pending → in_progress) is a separate gate - # whose rejection must surface as `invalid_state`, not - # `not_authorized` — agents react to those two errors differently. - if role not in ("cell_pm", "main_pm"): - return Envelope.not_authorized( - message="only cell_pm or main_pm may call i_will_plan", - remediate="this verb is reserved for PMs", - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ).with_introspection(task=t, role=role) - status = str(t.status) - if status != "pending": - # Idempotent re-entry: caller already owns this task in a - # post-claim state. Don't reject; the i_will_plan body will - # short-circuit on the same condition and return OK. - if status in ("claimed", "in_progress") and t.assigned_to == pm_agent_id: - return None - return Envelope.invalid_state( - message=f"task {task_id} is in {t.status}, expected pending", - remediate="call give_me_work() to find a pending task to plan", - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ) - if not plan or not plan.strip(): - return Envelope.tracing_gap( - missing=["plan"], - remediate=( - f"call i_will_plan(task_id='{task_id}'," - " plan='')" - ), - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ) - # Gate Set A: ALREADY_ACTIVE / PAUSED guards only. - # - # `pm_cannot_execute_code_guard` is INTENTIONALLY skipped here: - # PMs PLAN code-typed parent tasks all the time (they decompose - # the work into developer-claimable subtasks via delegate). - # The "PMs cannot execute code" rule belongs to the EXECUTION - # verb (`i_will_work_on`), not the PLANNING verb (`i_will_plan`). - # Pre-fix this guard fired on i_will_plan and deadlocked any - # code-typed parent task — see the 2026-05-08 smoke-test trace. - # - # `role_typed_claim_guard` is also skipped because i_will_plan - # services only PM roles which aren't in its allow-table. - guard = await self._run_claim_guards( - agent_id=pm_agent_id, - task=t, - skip_role_typed=True, - skip_pm_code=True, - ) - if guard: - return self._with_briefing( - guard, await self._briefing_for(pm_agent_id, task_id) - ) - return None - async def i_will_plan( self, pm_agent_id: UUID, task_id: UUID, plan: str ) -> Envelope: """PM mirror of i_will_work_on for parent tasks. - Transitions a pending task owned (or claimable by) this PM into - in_progress with the supplied plan. Required before a PM can call - ``delegate`` to spawn subtasks. + Atomic: spec.can_invoke_intent runs before any state mutation; + the composed (claim, set_plan, start) sequence is wrapped in a + savepoint by the runner so a mid-sequence failure rolls back + the DB. Idempotent re-entry: a respawned PM re-calling on a + task they already own in claimed/in_progress just refreshes + the heartbeat. """ t = await self.task.get(task_id) if t is None: @@ -1219,84 +1778,65 @@ class Choreographer: verb="i_will_plan", ) agent = await self.task.agent_for(pm_agent_id) - role = str(agent.role) if agent is not None else "cell_pm" - rejection = await self._i_will_plan_preflight(pm_agent_id, task_id, t, plan) - if rejection is not None: - rejection.with_introspection(task=t, role=role) + role_str = str(agent.role) if agent is not None else "cell_pm" + briefing = await self._briefing_for(pm_agent_id, task_id) + try: + role = spec_module.Role(role_str) + except ValueError: return await self._emit_rejection( - rejection, + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), agent_id=pm_agent_id, task_id=task_id, verb="i_will_plan", ) - - # Idempotent re-entry: respawned PM that already owns this task in - # claimed/in_progress short-circuits with the current state. Touch - # the heartbeat so the reaper sees fresh activity, then return OK - # pointing at delegate as the next call. Without this short-circuit - # the body would reach start() — which rejects because status is - # not 'claimed' on the in_progress branch — and emit a misleading - # invalid_state envelope. - status = str(t.status) - if status in ("claimed", "in_progress") and t.assigned_to == pm_agent_id: + spec_ctx = spec_module.Context( + plan=plan, + actor_id=pm_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + ) + ctx = _ClaimPlanStartContext( + agent_id=pm_agent_id, + task_id=task_id, + task=t, + role_str=role_str, + briefing=briefing, + plan=plan, + verb_name="i_will_plan", + ) + # Idempotent re-entry: PM already owns the task in_progress. + # Touch heartbeat and short-circuit before the spec gate (which + # would otherwise reject because in_progress is not a source state + # for the composed `claim` action). + if str(t.status) == "in_progress" and t.assigned_to == pm_agent_id: await self._touch(task_id) return Envelope.ok( - status=status, + status=str(t.status), task_id=str(task_id), - next=( - "task already claimed; delegate(parent_task_id, ...) for" - " each subtask, then i_am_idle" - ), - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ).with_introspection(task=t, role=role) - - # Always call claim() when status is pending — even if the PM is - # already in assigned_to (CEO pre-assigns root tasks at creation). - # claim() transitions pending → claimed; without that, start() below - # would refuse the claimed → in_progress transition and silently - # return None, leaving the task stuck in pending under a misleading - # OK envelope. claim() is idempotent for the same assignee. - if str(t.status) == "pending": - t = await self.task.claim(task_id, pm_agent_id) - if t is None: - return await self._emit_rejection( - Envelope.invalid_state( - message="claim failed", - remediate="task may already be claimed by another agent", - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ), - agent_id=pm_agent_id, - task_id=task_id, - verb="i_will_plan", - ) - await self.task.set_plan(task_id, plan) - t = await self.task.start(task_id, pm_agent_id) - if t is None: - # start() returns None on invalid status / ownership / missing - # plan. Surface the failure instead of pretending success. - return await self._emit_rejection( - Envelope.invalid_state( - message=f"start failed for task {task_id}", - remediate=( - "task not in a startable state" - " (claimed/paused/needs_revision) or no plan recorded" - ), - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ), - agent_id=pm_agent_id, - task_id=task_id, - verb="i_will_plan", + next=spec_module._INTENT_VERBS["i_will_plan"].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + # Recovery re-entry: task stuck in `claimed` (e.g. orchestrator restart + # or a partial-claim race) and the PM already owns it. The spec + # `claim` action's source-statuses do NOT include CLAIMED, so the spec + # gate would reject. Surface this as a runner call that runs only + # set_plan + start. Without this block, a PM reclaiming from a + # crashed mid-sequence would loop forever. + if str(t.status) == "claimed" and t.assigned_to == pm_agent_id: + envelope = await self._resume_from_claimed(ctx) + return await self._post_claim_journal_gate( + "i_will_plan", pm_agent_id, task_id, envelope ) - await self._touch(task_id) - return Envelope.ok( - status=str(t.status), - task_id=str(task_id), - next=( - "delegate(parent_task_id, title, description, assigned_to, team)" - " for each subtask, then i_am_idle" - ), - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ).with_introspection(task=t, role=role) + if rejection := await self._claim_plan_start_gate(ctx, role, spec_ctx): + return rejection + envelope = await self._claim_plan_start_run(ctx, agent, spec_ctx) + return await self._post_claim_journal_gate( + "i_will_plan", pm_agent_id, task_id, envelope + ) async def delegate( self, @@ -1306,9 +1846,14 @@ class Choreographer: ) -> Envelope: """Create a subtask under parent_task_id with delegation-chain validation. - Main PM may delegate to a Cell PM slug; a Cell PM may delegate to - its own team's developers. Anything else is rejected with an - explicit hint about the chain. + Atomic: spec.can_invoke_intent runs first for the role+state gate. + Delegate-specific gates the spec doesn't model (chain validation, + assignee-vs-task_type, enum coercion, parent-ownership, subtask + cap) run after the spec gate. Main PM may delegate to a Cell PM + slug; a Cell PM may delegate to its own team's developers. The + atomic ``create_subtask`` action is special — its handler raises + NotImplementedError because it requires DelegateInputs — so the + verb body owns the dispatch to ``_create_subtask_from_inputs``. """ parent = await self.task.get(parent_task_id) if parent is None: @@ -1319,77 +1864,103 @@ class Choreographer: verb="delegate", ) agent = await self.task.agent_for(pm_agent_id) - role = str(agent.role) if agent is not None else "cell_pm" - guard = await self._delegate_guard( - pm_agent_id, parent_task_id, parent, agent, inputs - ) - if guard is not None: - guard.with_introspection(task=parent, role=role) + role_str = str(agent.role) if agent is not None else "cell_pm" + briefing = await self._briefing_for(pm_agent_id, parent_task_id) + try: + role = spec_module.Role(role_str) + except ValueError: return await self._emit_rejection( - guard, + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=parent, role=role_str), + agent_id=pm_agent_id, + task_id=parent_task_id, + verb="delegate", + ) + spec_ctx = spec_module.Context( + actor_id=pm_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(parent), + ) + decision = spec_module.can_invoke_intent(role, "delegate", parent, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=parent, role=role_str + ), + agent_id=pm_agent_id, + task_id=parent_task_id, + verb="delegate", + ) + # Task 19: foundation/policy/task_completeness gate runs BEFORE the + # static/lifecycle guards. Auto-fill helpers patch unambiguous fields + # (team-from-slug, priority-from-parent), then `check(TASK_AT_CREATE, + # ...)` rejects under-filled payloads with `Envelope.incomplete_input` + # — the spec §5.2.1 interrogation pattern. Defense-in-depth: the + # service-layer raise from Task 18 still catches non-gateway callers. + completeness_env = self._delegate_completeness_check( + inputs, parent, briefing, role_str + ) + if completeness_env is not None: + return await self._emit_rejection( + completeness_env, + agent_id=pm_agent_id, + task_id=parent_task_id, + verb="delegate", + ) + # Spec gate passed. Run delegate-specific guards the spec doesn't + # model: tracing (journal:decision), chain validation, enum + # coercion + assignee-vs-task_type, and parent-ownership/subtask-cap. + guard = await self._delegate_extra_guards( + pm_agent_id, parent_task_id, parent, role_str, inputs + ) + if guard is not None: + return await self._emit_rejection( + guard.with_introspection(task=parent, role=role_str), agent_id=pm_agent_id, task_id=parent_task_id, verb="delegate", ) - new_task = await self._create_subtask_from_inputs( pm_agent_id, parent_task_id, parent, inputs ) return Envelope.ok( status="created", task_id=str(new_task.id), - next="continue delegating subtasks, or i_am_idle when done", - context_briefing=await self._briefing_for(pm_agent_id, parent_task_id), - ).with_introspection(task=new_task, role=role) + next=spec_module._INTENT_VERBS["delegate"].next_hint(new_task), + context_briefing=briefing, + ).with_introspection(task=new_task, role=role_str) # Gate Set B subtask cap (pre-gateway implicit, made explicit here). # Soft warn at 8, hard block at 13. Cap enforced by ``_subtask_cap_guard``. _SUBTASK_HARD_CAP: int = 12 - async def _delegate_guard( + async def _delegate_extra_guards( self, pm_agent_id: UUID, parent_task_id: UUID, parent: Any, - agent: Any, + role_str: str, inputs: DelegateInputs, ) -> Envelope | None: - """Return rejection Envelope if a delegate precondition fails; else None.""" - if guard := await self._delegate_role_guards( - pm_agent_id, parent_task_id, agent, inputs - ): - role = str(agent.role) if agent is not None else "" - return guard.with_introspection(task=parent, role=role) - if guard := await self._delegate_static_guards( - pm_agent_id, parent_task_id, parent, inputs - ): - return guard - # Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP - return await self._delegate_lifecycle_guards( - pm_agent_id, parent_task_id, parent - ) + """Delegate-specific guards the spec doesn't model. - async def _delegate_role_guards( - self, - pm_agent_id: UUID, - parent_task_id: UUID, - agent: Any, - inputs: DelegateInputs, - ) -> Envelope | None: - """Role + delegation-chain guards (the original two). + Order: tracing (journal:decision per VERB_REQUIREMENTS) -> + chain validation -> static (project_id, enum coercion, + assignee-vs-task_type) -> lifecycle (parent ownership + subtask + cap). Each returns an Envelope rejection or None to allow. - Role gate stays role-only here (not via is_verb_allowed) — the - parent-status check is a separate gate that must surface as - `invalid_state`, not `not_authorized`. See _i_will_plan_preflight - for the same rationale. + The role-only check is no longer here — ``spec.can_invoke_intent`` + handles role+state in the verb body before this is called. """ - if agent is None or agent.role not in ("cell_pm", "main_pm"): - return Envelope.not_authorized( - message="only cell_pm or main_pm may delegate", - remediate="this verb is reserved for PMs", - context_briefing=await self._briefing_for(pm_agent_id, parent_task_id), - ) - chain_error = self._validate_delegation_chain(agent.role, inputs.assigned_to) + # Pre-gateway PM.md required journal:decision before each delegate. + if env := await self._check_pm_decision_required( + "delegate", pm_agent_id, parent_task_id, parent + ): + return env + chain_error = self._validate_delegation_chain(role_str, inputs.assigned_to) if chain_error is not None: return Envelope.not_authorized( message=chain_error, @@ -1400,7 +1971,14 @@ class Choreographer: ), context_briefing=await self._briefing_for(pm_agent_id, parent_task_id), ) - return None + if guard := await self._delegate_static_guards( + pm_agent_id, parent_task_id, parent, inputs + ): + return guard + # Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP + return await self._delegate_lifecycle_guards( + pm_agent_id, parent_task_id, parent + ) async def _delegate_static_guards( self, @@ -1411,10 +1989,11 @@ class Choreographer: ) -> Envelope | None: """project_id / enum guards. Pure data-shape checks. - The slug-validity check used to live here, but `_delegate_role_guards` - runs first and `_validate_delegation_chain` rejects any slug outside - the allowed delegation targets — which is a strict subset of - `AGENT_UUIDS` — so any AGENT_UUIDS check here was unreachable. + The slug-validity check used to live here, but + `_validate_delegation_chain` runs first (in `_delegate_extra_guards`) + and rejects any slug outside the allowed delegation targets — + which is a strict subset of `AGENT_UUIDS` — so any AGENT_UUIDS + check here was unreachable. """ if parent.project_id is None: return Envelope.invalid_state( @@ -1447,9 +2026,7 @@ class Choreographer: _CELL_PM_SLUGS: ClassVar[frozenset[str]] = frozenset({"be-pm", "fe-pm", "ux-pm"}) @staticmethod - def _validate_assignee_task_type( - assigned_to: str, task_type: str - ) -> str | None: + def _validate_assignee_task_type(assigned_to: str, task_type: str) -> str | None: """Reject role-vs-type misclassifications. Rule (2026-05-09 smoke Bug B): when delegating to a Cell PM, the @@ -1535,6 +2112,69 @@ class Choreographer: Complexity(inputs.estimated_complexity), ) + def _delegate_completeness_check( + self, + inputs: DelegateInputs, + parent: Any, + briefing: dict[str, Any], + role_str: str, + ) -> Envelope | None: + """Task 19: foundation/policy/task_completeness gate for delegate. + + Auto-fills unambiguous fields (team-from-slug, priority-from-parent) + without overwriting explicit values, then runs `check(TASK_AT_CREATE, + ...)` against the payload. Returns: + + - ``None`` when every TASK_AT_CREATE requirement is satisfied (the + verb continues into the static/lifecycle guards). + - ``Envelope.incomplete_input`` (with `with_introspection` applied) + when any field is missing — the agent gets a structured + field-by-field guide (spec §5.2.1 interrogation pattern). + + The `acceptance_criteria=inputs.acceptance_criteria or []` collapse + at `_create_subtask_from_inputs` was removed alongside this gate, + so under-filled payloads now hit the service-layer raise (Task 18) + instead of being silently substituted. This method is the + gateway-side defense; the service raise is defense-in-depth for + non-gateway callers. + """ + from types import SimpleNamespace + + from roboco.foundation.policy import task_completeness as tc + + payload: dict[str, Any] = { + "title": inputs.title, + "description": inputs.description, + "assigned_to": inputs.assigned_to, + "team": inputs.team, + "task_type": inputs.task_type, + "nature": inputs.nature, + "estimated_complexity": inputs.estimated_complexity, + "acceptance_criteria": inputs.acceptance_criteria, + } + # Auto-fill (spec §5.2.1 (a)) — never overwrites explicit values. + # team-from-slug is harmless when the caller already supplied team; + # priority-from-parent records `__priority_inherited=True` for + # post-create journal:note (best-effort observability). + payload = tc.fill_team_from_assignee(payload) + payload = tc.fill_priority_from_parent(payload, parent) + completeness_input = SimpleNamespace( + **{k: v for k, v in payload.items() if not k.startswith("__")} + ) + result = tc.check(tc.TASK_AT_CREATE, completeness_input) + if result.passed: + return None + return Envelope.incomplete_input( + missing=result.missing, + field_hints=result.field_hints, + remediate=( + "re-issue delegate(...) with these fields filled: " + f"{', '.join(result.missing)}. Each field's required shape " + "is in `field_hints`." + ), + context_briefing=briefing, + ).with_introspection(task=parent, role=role_str) + async def _create_subtask_from_inputs( self, pm_agent_id: UUID, @@ -1542,22 +2182,70 @@ class Choreographer: parent: Any, inputs: DelegateInputs, ) -> Any: - """Resolve enums + AGENT_UUIDS slug and call TaskService.create_subtask.""" + """Resolve enums + AGENT_UUIDS slug and call TaskService.create_subtask. + + By contract, callers (the `delegate` verb body) MUST run + `_delegate_completeness_check` first, so `inputs.acceptance_criteria` + and `inputs.nature` are guaranteed non-None / non-empty here. The + defensive `TaskCompletenessError` raises preserve correctness if + a future caller bypasses the gateway path — defense-in-depth in + line with Task 18's service-layer raise. + """ + from roboco.foundation.policy.task_completeness import TaskCompletenessError + from roboco.models.base import TaskNature from roboco.models.task import TaskCreateRequest from roboco.seeds.initial_data import AGENT_UUIDS team_enum, type_enum, complexity_enum = self._resolve_delegate_enums(inputs) assignee_id = UUID(AGENT_UUIDS[inputs.assigned_to]) + # Task 19: the `or []` collapse was removed. The gateway runs + # `_delegate_completeness_check` BEFORE this helper, so empty/None + # acceptance_criteria here means a non-gateway caller bypassed the + # check. Raise so the service-layer raise (Task 18) can attach the + # field hints — never silently substitute. + if not inputs.acceptance_criteria: + raise TaskCompletenessError( + missing=["acceptance_criteria"], + field_hints={ + "acceptance_criteria": ( + "non-empty list[str]; each item describes a verifiable outcome" + ) + }, + message=( + "_create_subtask_from_inputs called with empty " + "acceptance_criteria — completeness check must run first" + ), + ) + if inputs.nature is None: + raise TaskCompletenessError( + missing=["nature"], + field_hints={ + "nature": "one of: technical | non_technical", + }, + message=( + "_create_subtask_from_inputs called with no nature — " + "completeness check must run first" + ), + ) + try: + nature_enum = TaskNature(inputs.nature) + except ValueError as exc: + raise TaskCompletenessError( + missing=["nature"], + field_hints={"nature": "one of: technical | non_technical"}, + message=f"invalid nature {inputs.nature!r}: {exc}", + ) from exc req = TaskCreateRequest( title=inputs.title, description=inputs.description, - acceptance_criteria=inputs.acceptance_criteria or [], + acceptance_criteria=inputs.acceptance_criteria, team=team_enum, created_by=pm_agent_id, project_id=UUID(str(parent.project_id)), parent_task_id=parent_task_id, assigned_to=assignee_id, task_type=type_enum, + nature=nature_enum, estimated_complexity=complexity_enum, ) return await self.task.create_subtask(req) @@ -1596,12 +2284,19 @@ class Choreographer: async def submit_up(self, pm_agent_id: UUID, task_id: UUID, notes: str) -> Envelope: """Cell PM bubbles a finished cell-scope task up to the Main PM. - Opens a cell-level PR into the parent (Main PM) branch, transitions - the task to ``awaiting_pm_review``, and reassigns to the Main PM. - Required preconditions: caller owns the task, all subtasks - terminal, journal:decision logged, notes >= 20 chars. + Spec gate runs first and enforces role membership (cell_pm only) + plus the composed ``submit_pm_review`` action's source-status + constraint (IN_PROGRESS only). After the gate accepts, the + verb-specific ``_submit_up_guard`` runs the rest of the + preflight checks the spec doesn't model: ownership, notes + length, journal:decision presence, subtasks-terminal, branch + present. Then ``VerbRunner.run_intent("submit_up", ...)`` + dispatches the (submit_pm_review,) atomic chain plus the + (create_pr,) side effect inside a savepoint. After the runner + returns, the task is handed off to the Main PM (reassign + a2a). """ t = await self.task.get(task_id) + briefing = await self._briefing_for(pm_agent_id, task_id) if t is None: return await self._emit_rejection( Envelope.not_found(message=f"task {task_id} not found"), @@ -1610,10 +2305,43 @@ class Choreographer: verb="submit_up", ) agent = await self.task.agent_for(pm_agent_id) - role = str(agent.role) if agent is not None else "cell_pm" + role_str = str(agent.role) if agent is not None else "cell_pm" + try: + role = spec_module.Role(role_str) + except ValueError: + return await self._emit_rejection( + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=pm_agent_id, + task_id=task_id, + verb="submit_up", + ) + spec_ctx = spec_module.Context( + actor_id=pm_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + notes=notes, + ) + decision = spec_module.can_invoke_intent(role, "submit_up", t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), + agent_id=pm_agent_id, + task_id=task_id, + verb="submit_up", + ) + + # Verb-specific preflight: ownership + notes-length + journal:decision + # + subtasks-terminal + branch-present. None of these are modelled by + # the spec yet — keep them in the verb body. guard = await self._submit_up_guard(pm_agent_id, task_id, t, notes) if guard is not None: - guard.with_introspection(task=t, role=role) + guard.with_introspection(task=t, role=role_str) return await self._emit_rejection( guard, agent_id=pm_agent_id, @@ -1621,27 +2349,55 @@ class Choreographer: verb="submit_up", ) - parent_branch = parent_branch_for(t.branch_name) - await self.git.create_pr(t.branch_name, parent=parent_branch, is_root_pr=False) - t = await self.task.submit_pm_review(pm_agent_id, task_id, notes) - if t is None: + outcome = await self._submit_up_run_intent( + t, agent, spec_ctx, briefing, role_str + ) + if isinstance(outcome, Envelope): return await self._emit_rejection( - Envelope.invalid_state( - message="could not transition to awaiting_pm_review", - remediate="check task state — must be in_progress with PR ready", - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ), + outcome, agent_id=pm_agent_id, task_id=task_id, verb="submit_up", ) + t = outcome await self._handoff_to_main_pm(pm_agent_id, task_id) return Envelope.ok( - status="awaiting_pm_review", + status=str(t.status), task_id=str(task_id), - next="idle until Main PM reviews", - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ).with_introspection(task=t, role=role) + next=spec_module._INTENT_VERBS["submit_up"].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + + async def _submit_up_run_intent( + self, + t: Any, + agent: Any, + spec_ctx: spec_module.Context, + briefing: dict[str, Any], + role_str: str, + ) -> Any: + """Dispatch the submit_up composition through VerbRunner. + + Returns the post-composition task on success, or an + ``invalid_state`` Envelope when the runner raises or the + underlying ``submit_pm_review`` returns ``None``. + """ + runner = self._verb_runner() + try: + after = await runner.run_intent("submit_up", t, agent, spec_ctx) + except Exception as exc: + return Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + if after is None: + return Envelope.invalid_state( + message="could not transition to awaiting_pm_review", + remediate="check task state — must be in_progress with PR ready", + context_briefing=briefing, + ) + return after async def _submit_up_guard( self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str @@ -1652,7 +2408,7 @@ class Choreographer: ) if ownership is not None: return ownership - return await self._submit_up_state_guard(pm_agent_id, task_id, t) + return await self._submit_up_state_guard(pm_agent_id, task_id, t, notes) async def _submit_up_ownership_guard( self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str @@ -1685,20 +2441,19 @@ class Choreographer: return None async def _submit_up_state_guard( - self, pm_agent_id: UUID, task_id: UUID, t: Any + self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str ) -> Envelope | None: - """Journal + subtask-closure + branch guards for submit_up.""" - has_decision = await self.journal.has_decision_for_task(pm_agent_id, task_id) - if not has_decision: - from roboco.services.gateway.remediation import ( - hint_for_missing_journal_decision, - ) + """Journal + subtask-closure + branch guards for submit_up. - return Envelope.tracing_gap( - missing=["journal:decision"], - remediate=hint_for_missing_journal_decision(), - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ) + Tracing gates (journal:decision, journal:reflect, notes>=min, + subtasks_terminal) are evaluated by ``_check_submit_up_gates`` + which consumes ``VERB_REQUIREMENTS["submit_up"]``. The + ``_subtasks_not_terminal_envelope`` call is kept as a fallback + because its remediation enumerates the non-terminal subtask ids + — strictly richer than the foundation hint. + """ + if env := await self._check_submit_up_gates(pm_agent_id, task_id, notes): + return env if env := await self._subtasks_not_terminal_envelope( pm_agent_id, task_id, context_phrase="bubbling up" ): @@ -1852,18 +2607,11 @@ class Choreographer: verb="unblock", ) - has_decision = await self.journal.has_decision_for_task(pm_agent_id, task_id) - if not has_decision: - from roboco.services.gateway.remediation import ( - hint_for_missing_journal_decision, - ) - + if env := await self._check_pm_decision_required( + "unblock", pm_agent_id, task_id, t + ): return await self._emit_rejection( - Envelope.tracing_gap( - missing=["journal:decision"], - remediate=hint_for_missing_journal_decision(), - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ).with_introspection(task=t, role=role), + env.with_introspection(task=t, role=role), agent_id=pm_agent_id, task_id=task_id, verb="unblock", @@ -1883,9 +2631,17 @@ class Choreographer: ).with_introspection(task=t, role=role) async def _cell_pm_complete_guard( - self, pm_agent_id: UUID, task_id: UUID, t: Any + self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str ) -> Envelope | None: - """Return a rejection Envelope if pre-merge guards fail; else None.""" + """Return a rejection Envelope if pre-merge guards fail; else None. + + Tracing gates (journal:decision, journal:reflect, notes>=min) are + evaluated by ``_check_complete_gates`` which consumes + ``VERB_REQUIREMENTS["complete"]``. The + ``_subtasks_not_terminal_envelope`` call is kept inline because + its remediation enumerates the non-terminal subtask ids — strictly + richer than the foundation hint. + """ if t.assigned_to != pm_agent_id: return Envelope.not_authorized( message="not assigned to you", @@ -1900,17 +2656,8 @@ class Choreographer: remediate="this task is not ready for completion", context_briefing=await self._briefing_for(pm_agent_id, task_id), ) - has_decision = await self.journal.has_decision_for_task(pm_agent_id, task_id) - if not has_decision: - from roboco.services.gateway.remediation import ( - hint_for_missing_journal_decision, - ) - - return Envelope.tracing_gap( - missing=["journal:decision"], - remediate=hint_for_missing_journal_decision(), - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ) + if env := await self._check_complete_gates(pm_agent_id, task_id, notes): + return env if env := await self._subtasks_not_terminal_envelope( pm_agent_id, task_id, context_phrase="completing parent" ): @@ -1938,7 +2685,7 @@ class Choreographer: task_id=task_id, verb="cell_pm_complete", ) - guard = await self._cell_pm_complete_guard(pm_agent_id, task_id, t) + guard = await self._cell_pm_complete_guard(pm_agent_id, task_id, t, notes) if guard is not None: guard.with_introspection(task=t, role="cell_pm") return await self._emit_rejection( @@ -1968,7 +2715,7 @@ class Choreographer: return Envelope.ok( status=str(t.status), task_id=str(task_id), - next=f"merged into {target}; triage() for next item", + next=spec_module._INTENT_VERBS["complete"].next_hint(t), context_briefing=await self._briefing_for(pm_agent_id, task_id), ).with_introspection(task=t, role="cell_pm") @@ -1998,9 +2745,17 @@ class Choreographer: await self.task.reassign(parent_task_id, pm_agent.id) async def _main_pm_complete_guard( - self, main_pm_agent_id: UUID, root_task_id: UUID, t: Any + self, main_pm_agent_id: UUID, root_task_id: UUID, t: Any, notes: str ) -> Envelope | None: - """Return a rejection Envelope if pre-escalation guards fail; else None.""" + """Return a rejection Envelope if pre-escalation guards fail; else None. + + Tracing gates (journal:decision, journal:reflect, notes>=min) are + evaluated by ``_check_complete_gates`` which consumes + ``VERB_REQUIREMENTS["complete"]``. The + ``_subtasks_not_terminal_envelope`` call is kept inline because + its remediation enumerates the non-terminal subtask ids — strictly + richer than the foundation hint. + """ if t.assigned_to != main_pm_agent_id: return Envelope.not_authorized( message="not assigned to you", @@ -2032,21 +2787,10 @@ class Choreographer: main_pm_agent_id, root_task_id ), ) - has_decision = await self.journal.has_decision_for_task( - main_pm_agent_id, root_task_id - ) - if not has_decision: - from roboco.services.gateway.remediation import ( - hint_for_missing_journal_decision, - ) - - return Envelope.tracing_gap( - missing=["journal:decision"], - remediate=hint_for_missing_journal_decision(), - context_briefing=await self._briefing_for( - main_pm_agent_id, root_task_id - ), - ) + if env := await self._check_complete_gates( + main_pm_agent_id, root_task_id, notes + ): + return env if env := await self._subtasks_not_terminal_envelope( main_pm_agent_id, root_task_id, context_phrase="escalating to CEO" ): @@ -2065,7 +2809,9 @@ class Choreographer: task_id=root_task_id, verb="main_pm_complete", ) - guard = await self._main_pm_complete_guard(main_pm_agent_id, root_task_id, t) + guard = await self._main_pm_complete_guard( + main_pm_agent_id, root_task_id, t, notes + ) if guard is not None: guard.with_introspection(task=t, role="main_pm") return await self._emit_rejection( @@ -2095,37 +2841,90 @@ class Choreographer: return Envelope.ok( status=str(t.status), task_id=str(root_task_id), - next="idle until CEO approves (or rejects) via UI", + next=spec_module._INTENT_VERBS["complete"].next_hint(t), context_briefing=await self._briefing_for(main_pm_agent_id, root_task_id), ).with_introspection(task=t, role="main_pm") async def complete(self, agent_id: UUID, task_id: UUID, notes: str) -> Envelope: - """Dispatch to cell_pm_complete or main_pm_complete based on agent role.""" - agent = await self.task.agent_for(agent_id) - if agent.role == "cell_pm": - return await self.cell_pm_complete(agent_id, task_id, notes) - if agent.role == "main_pm": - return await self.main_pm_complete(agent_id, task_id, notes) + """Dispatch to cell_pm_complete or main_pm_complete based on agent role. + + Spec gate runs first (role membership + composed ``complete`` + action's source-status constraint, AWAITING_PM_REVIEW only). + Both rejections (role not in spec._PM_ROLES, status not awaiting_pm_review) + flow through ``spec.can_invoke_intent`` and surface as the + spec-supplied rejection_kind. After the gate accepts, the + verb body owns dispatch — ``complete`` has two divergent + runtime paths (Cell PM merges leaf into parent branch; Main PM + opens master PR + escalates to CEO) that can't be expressed as + a single VerbRunner composition. Each lower-level method keeps + its own pre-flight guards (``_cell_pm_complete_guard`` / + ``_main_pm_complete_guard``) — those model journal:decision + presence, subtasks-terminal, and PR-mergeability checks the + spec doesn't model yet. + """ t = await self.task.get(task_id) - rejection = Envelope.not_authorized( - message=f"role {agent.role} cannot complete tasks via this verb", - remediate="only cell_pm and main_pm can call complete", - context_briefing=await self._briefing_for(agent_id, task_id), - ) - if t is not None: - rejection.with_introspection(task=t, role=str(agent.role)) - return await self._emit_rejection( - rejection, - agent_id=agent_id, - task_id=task_id, - verb="complete", + briefing = await self._briefing_for(agent_id, task_id) + if t is None: + return await self._emit_rejection( + Envelope.not_found(message=f"task {task_id} not found"), + agent_id=agent_id, + task_id=task_id, + verb="complete", + ) + agent = await self.task.agent_for(agent_id) + role_str = str(agent.role) if agent is not None else "developer" + try: + role = spec_module.Role(role_str) + except ValueError: + return await self._emit_rejection( + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb="complete", + ) + spec_ctx = spec_module.Context( + actor_id=agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), ) + decision = spec_module.can_invoke_intent(role, "complete", t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), + agent_id=agent_id, + task_id=task_id, + verb="complete", + ) + # Spec gate passed — role is CELL_PM or MAIN_PM, status is + # AWAITING_PM_REVIEW. Verb body owns dispatch from here. + if role_str == "cell_pm": + return await self.cell_pm_complete(agent_id, task_id, notes) + # role_str == "main_pm" — spec._PM_ROLES has only these two members. + return await self.main_pm_complete(agent_id, task_id, notes) async def escalate_up( self, pm_agent_id: UUID, task_id: UUID, reason: str ) -> Envelope: - """Escalate a task to the agent's escalation_target role.""" + """Escalate a task to the agent's escalation_target role. + + Spec gate runs first and enforces role membership (cell_pm or + main_pm only — escalate_up's IntentSpec has ``composes=()``, so + the spec does not enforce a source-status constraint). After the + gate accepts, the verb-specific preflight guards stay: + ``journal:decision`` presence (the spec doesn't model journal + side effects) and ``escalation_target`` configuration on the + actor's agent record (also out of the spec's scope). Then the + verb body owns dispatch via ``task.escalate(...)`` because + ``composes=()`` (no atomic action for the runner to run). + """ t = await self.task.get(task_id) + briefing = await self._briefing_for(pm_agent_id, task_id) if t is None: return await self._emit_rejection( Envelope.not_found(message=f"task {task_id} not found"), @@ -2134,38 +2933,51 @@ class Choreographer: verb="escalate_up", ) me = await self.task.agent_for(pm_agent_id) - role = str(me.role) if me is not None else "cell_pm" - - has_decision = await self.journal.has_decision_for_task(pm_agent_id, task_id) - if not has_decision: - from roboco.services.gateway.remediation import ( - hint_for_missing_journal_decision, - ) - + role_str = str(me.role) if me is not None else "cell_pm" + try: + role = spec_module.Role(role_str) + except ValueError: return await self._emit_rejection( - Envelope.tracing_gap( - missing=["journal:decision"], - remediate=hint_for_missing_journal_decision(), - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ).with_introspection(task=t, role=role), + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=pm_agent_id, + task_id=task_id, + verb="escalate_up", + ) + spec_ctx = spec_module.Context( + actor_id=pm_agent_id, + actor_slug=getattr(me, "slug", None) if me is not None else None, + original_developer_slug=_extract_original_developer(t), + notes=reason, + ) + decision = spec_module.can_invoke_intent(role, "escalate_up", t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), agent_id=pm_agent_id, task_id=task_id, verb="escalate_up", ) + preflight = await self._escalate_up_preflight( + pm_agent_id, t, me, briefing, role_str + ) + if preflight is not None: + return await self._emit_rejection( + preflight, + agent_id=pm_agent_id, + task_id=task_id, + verb="escalate_up", + ) + + # Verb body owns dispatch — escalate_up's IntentSpec has + # composes=(), so VerbRunner has no atomic action to run. target_slug = me.escalation_target if me else None - if not target_slug: - return await self._emit_rejection( - Envelope.invalid_state( - message="no escalation target configured for your role", - remediate="check agents_config.py ESCALATION_CHAIN for your slug", - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ).with_introspection(task=t, role=role), - agent_id=pm_agent_id, - task_id=task_id, - verb="escalate_up", - ) - t = await self.task.escalate(pm_agent_id, task_id, reason) if t is None: return await self._emit_rejection( @@ -2178,7 +2990,7 @@ class Choreographer: f"verify {target_slug} exists in agents table and that the " "task is still present" ), - context_briefing=await self._briefing_for(pm_agent_id, task_id), + context_briefing=briefing, ), agent_id=pm_agent_id, task_id=task_id, @@ -2188,16 +3000,60 @@ class Choreographer: status=str(t.status), task_id=str(task_id), next=f"escalated to {target_slug}; idle until they respond", - context_briefing=await self._briefing_for(pm_agent_id, task_id), - ).with_introspection(task=t, role=role) + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + + async def _escalate_up_preflight( + self, + pm_agent_id: UUID, + t: Any, + me: Any, + briefing: dict[str, Any], + role_str: str, + ) -> Envelope | None: + """Verb-specific preflight gates for escalate_up. + + Returns a rejection envelope when the gate fires; ``None`` to + proceed. The spec doesn't model journal side effects or agent + metadata (escalation_target slug), so these gates stay in the + verb body. The journal-decision check is delegated to + ``_check_pm_decision_required`` which consumes + ``VERB_REQUIREMENTS["escalate_up"]``. + """ + if env := await self._check_pm_decision_required( + "escalate_up", pm_agent_id, t.id, t + ): + return env.with_introspection(task=t, role=role_str) + target_slug = me.escalation_target if me else None + if not target_slug: + return Envelope.invalid_state( + message="no escalation target configured for your role", + remediate="check agents_config.py ESCALATION_CHAIN for your slug", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + return None # --- Phase 4 (board) verbs --- async def escalate_to_ceo( self, agent_id: UUID, task_id: UUID, reason: str ) -> Envelope: - """Board/Main PM escalates task_id to CEO with reason.""" + """Board/Main PM escalates task_id to CEO with reason. + + Spec gate runs first and enforces role membership (main_pm, + product_owner, head_marketing) plus the composed + ``escalate_to_ceo`` action's source-status constraint + (AWAITING_PM_REVIEW only). After the gate accepts, the + verb-specific preflight guard stays: ``journal:decision`` + presence (the spec doesn't model journal side effects). Then + ``VerbRunner.run_intent("escalate_to_ceo", ...)`` dispatches the + (escalate_to_ceo,) atomic chain wrapped in a savepoint. After + the runner returns, the task is reassigned to None — the CEO + acts via the UI, not as a spawnable agent (mirrors + main_pm_complete). + """ t = await self.task.get(task_id) + briefing = await self._briefing_for(agent_id, task_id) if t is None: return await self._emit_rejection( Envelope.not_found(message=f"task {task_id} not found"), @@ -2206,56 +3062,72 @@ class Choreographer: verb="escalate_to_ceo", ) me = await self.task.agent_for(agent_id) - role = str(me.role) if me is not None else "main_pm" - if me.role not in ("main_pm", "product_owner", "head_marketing"): + role_str = str(me.role) if me is not None else "main_pm" + try: + role = spec_module.Role(role_str) + except ValueError: return await self._emit_rejection( Envelope.not_authorized( - message=f"role {me.role} cannot escalate to CEO directly", - remediate="use escalate_up() to go through your escalation chain", - context_briefing=await self._briefing_for(agent_id, task_id), - ).with_introspection(task=t, role=role), + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), agent_id=agent_id, task_id=task_id, verb="escalate_to_ceo", ) - if str(t.status) != "awaiting_pm_review": + spec_ctx = spec_module.Context( + actor_id=agent_id, + actor_slug=getattr(me, "slug", None) if me is not None else None, + original_developer_slug=_extract_original_developer(t), + notes=reason, + ) + decision = spec_module.can_invoke_intent(role, "escalate_to_ceo", t, spec_ctx) + if not decision.allowed: return await self._emit_rejection( - Envelope.invalid_state( - message=( - f"task {task_id} is in {t.status}, expected awaiting_pm_review" - ), - remediate="this task is not at the gate for CEO approval", - context_briefing=await self._briefing_for(agent_id, task_id), - ).with_introspection(task=t, role=role), + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), agent_id=agent_id, task_id=task_id, verb="escalate_to_ceo", ) - has_decision = await self.journal.has_decision_for_task(agent_id, task_id) - if not has_decision: - from roboco.services.gateway.remediation import ( - hint_for_missing_journal_decision, - ) + # Verb-specific preflight: journal:decision presence (out of spec scope). + # Delegates to _check_pm_decision_required which consumes + # VERB_REQUIREMENTS["escalate_to_ceo"]. + if env := await self._check_pm_decision_required( + "escalate_to_ceo", agent_id, task_id, t + ): return await self._emit_rejection( - Envelope.tracing_gap( - missing=["journal:decision"], - remediate=hint_for_missing_journal_decision(), - context_briefing=await self._briefing_for(agent_id, task_id), - ).with_introspection(task=t, role=role), + env.with_introspection(task=t, role=role_str), + agent_id=agent_id, + task_id=task_id, + verb="escalate_to_ceo", + ) + + runner = self._verb_runner() + try: + t = await runner.run_intent("escalate_to_ceo", t, me, spec_ctx) + except Exception as exc: + return await self._emit_rejection( + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), agent_id=agent_id, task_id=task_id, verb="escalate_to_ceo", ) - t = await self.task.escalate_to_ceo(task_id, agent_role=me.role, notes=reason) # Same as main_pm_complete: CEO acts via UI, not as a spawnable agent. await self.task.reassign(task_id, None) return Envelope.ok( status=str(t.status), task_id=str(task_id), - next="idle until CEO acts via UI", - context_briefing=await self._briefing_for(agent_id, task_id), - ).with_introspection(task=t, role=role) + next=spec_module._INTENT_VERBS["escalate_to_ceo"].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) # board_triage + auditor_triage moved to ``board.py`` as the first # per-role mixin extraction (audit P2-2). The Choreographer class is diff --git a/roboco/services/gateway/choreographer/_protocol.py b/roboco/services/gateway/choreographer/_protocol.py index b4d13bcd..c085c853 100644 --- a/roboco/services/gateway/choreographer/_protocol.py +++ b/roboco/services/gateway/choreographer/_protocol.py @@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from uuid import UUID + from roboco.services.gateway.choreographer._verb_runner import VerbRunner from roboco.services.gateway.envelope import Envelope @@ -71,11 +72,20 @@ class ChoreographerHelpers: *, agent_id: UUID, task: Any, - skip_role_typed: bool = False, - skip_pm_code: bool = False, skip_sequence: bool = False, ) -> Envelope | None: raise NotImplementedError async def _touch(self, task_id: UUID | None) -> None: raise NotImplementedError + + def _verb_runner(self) -> VerbRunner: + raise NotImplementedError + + async def _build_tracing_gap( + self, + agent_id: UUID, + task_id: UUID, + missing: list[str], + ) -> Envelope: + raise NotImplementedError diff --git a/roboco/services/gateway/choreographer/_verb_runner.py b/roboco/services/gateway/choreographer/_verb_runner.py new file mode 100644 index 00000000..3fd30434 --- /dev/null +++ b/roboco/services/gateway/choreographer/_verb_runner.py @@ -0,0 +1,196 @@ +"""Composed-actions runner with atomicity invariant. + +Used by every choreographer verb body that has a non-trivial +composition. The runner: + + 1. Wraps the composed atomic actions in `session.begin_nested()` + (a SAVEPOINT). A mid-sequence failure rolls the DB back to the + pre-verb state. + 2. Runs side effects (git push, PR creation, etc.) AFTER the + savepoint commits, never before. Each side effect is itself + idempotent + retryable per the open_pr atomicity pattern. + +Preconditions are NOT this runner's concern — `spec.can_invoke_intent` +runs before the verb body, so by the time the runner is called the +Decision is `allow`. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from roboco.foundation.policy import lifecycle as spec + +_AtomicHandler = Callable[[Any, Any, Any, spec.Context], Awaitable[Any]] +_SideEffectHandler = Callable[[Any, Any, Any], Awaitable[Any]] + + +@dataclass(frozen=True) +class VerbRunner: + """Lightweight composition runner; one instance per choreographer.""" + + task_service: Any + git_service: Any + + async def run_intent( + self, + intent_name: str, + task: Any, + agent: Any, + context: spec.Context, + ) -> Any: + """Run composed atomic actions in order, then side effects. + + Returns the final task object (post-composition). Raises whatever + the underlying TaskService methods raise; the savepoint context + rolls the DB back on raise. + """ + intent = spec._INTENT_VERBS[intent_name] + async with self.task_service.session.begin_nested(): + for action_name in intent.composes: + task = await self._dispatch_atomic(action_name, task, agent, context) + for side_effect_name in intent.side_effects: + await self._dispatch_side_effect(side_effect_name, task, agent) + return task + + async def _dispatch_atomic( + self, action_name: str, task: Any, agent: Any, context: spec.Context + ) -> Any: + """Dispatch one atomic action by name. Returns the post-action task.""" + handler = self._atomic_handlers().get(action_name) + if handler is None: + raise ValueError(f"unknown atomic action '{action_name}'") + return await handler(self, task, agent, context) + + async def _dispatch_side_effect( + self, side_effect_name: str, task: Any, agent: Any + ) -> Any: + """Dispatch one side effect by name. Idempotent operations only.""" + handler = self._side_effect_handlers().get(side_effect_name) + if handler is None: + raise ValueError(f"unknown side effect '{side_effect_name}'") + return await handler(self, task, agent) + + # -- Atomic handlers --------------------------------------------------- + + async def _do_claim(self, task: Any, agent: Any, _ctx: spec.Context) -> Any: + return await self.task_service.claim(task.id, agent.id) + + async def _do_set_plan(self, task: Any, _agent: Any, ctx: spec.Context) -> Any: + return await self.task_service.set_plan(task.id, ctx.plan or "") + + async def _do_start(self, task: Any, agent: Any, _ctx: spec.Context) -> Any: + return await self.task_service.start(task.id, agent.id) + + async def _do_submit_verification( + self, task: Any, agent: Any, ctx: spec.Context + ) -> Any: + return await self.task_service.submit_verification( + agent.id, task.id, ctx.notes or "" + ) + + async def _do_submit_qa(self, task: Any, agent: Any, ctx: spec.Context) -> Any: + return await self.task_service.submit_qa(agent.id, task.id, ctx.notes or "") + + async def _do_qa_pass(self, task: Any, agent: Any, ctx: spec.Context) -> Any: + return await self.task_service.qa_pass(agent.id, task.id, ctx.notes or "") + + async def _do_qa_fail(self, task: Any, agent: Any, ctx: spec.Context) -> Any: + return await self.task_service.qa_fail( + agent.id, task.id, ctx.notes or "", list(ctx.issues) + ) + + async def _do_docs_complete(self, task: Any, _agent: Any, ctx: spec.Context) -> Any: + return await self.task_service.docs_complete(task.id, doc_notes=ctx.notes or "") + + async def _do_complete(self, task: Any, agent: Any, ctx: spec.Context) -> Any: + return await self.task_service.cell_pm_complete( + agent.id, task.id, ctx.notes or "" + ) + + async def _do_submit_pm_review( + self, task: Any, agent: Any, ctx: spec.Context + ) -> Any: + return await self.task_service.submit_pm_review( + agent.id, task.id, ctx.notes or "" + ) + + async def _do_escalate_to_ceo( + self, task: Any, agent: Any, ctx: spec.Context + ) -> Any: + # Use the actor's real role — escalate_to_ceo is allow-listed for + # main_pm, product_owner, head_marketing in the spec, and the task + # service stamps the escalator's role into the audit trail. + agent_role = str(agent.role) if agent is not None else "main_pm" + return await self.task_service.escalate_to_ceo( + task_id=task.id, agent_role=agent_role, notes=ctx.notes or "" + ) + + async def _do_block(self, task: Any, agent: Any, ctx: spec.Context) -> Any: + return await self.task_service.escalate(agent.id, task.id, ctx.notes or "") + + async def _do_unblock(self, task: Any, agent: Any, _ctx: spec.Context) -> Any: + return await self.task_service.unblock_with_restore( + agent.id, task.id, restore=True + ) + + async def _do_resume(self, task: Any, agent: Any, _ctx: spec.Context) -> Any: + return await self.task_service.resume_for_agent(task.id, agent.id) + + async def _do_create_subtask( + self, _task: Any, _agent: Any, _ctx: spec.Context + ) -> Any: + raise NotImplementedError( + "create_subtask requires DelegateInputs; verb body owns dispatch" + ) + + @classmethod + def _atomic_handlers(cls) -> dict[str, _AtomicHandler]: + return { + "claim": cls._do_claim, + "set_plan": cls._do_set_plan, + "start": cls._do_start, + "submit_verification": cls._do_submit_verification, + "submit_qa": cls._do_submit_qa, + "qa_pass": cls._do_qa_pass, + "qa_fail": cls._do_qa_fail, + "docs_complete": cls._do_docs_complete, + "complete": cls._do_complete, + "submit_pm_review": cls._do_submit_pm_review, + "escalate_to_ceo": cls._do_escalate_to_ceo, + "block": cls._do_block, + "unblock": cls._do_unblock, + "resume": cls._do_resume, + "create_subtask": cls._do_create_subtask, + } + + # -- Side-effect handlers --------------------------------------------- + + async def _do_push_branch(self, task: Any, _agent: Any) -> Any: + return await self.git_service.push_branch(task.branch_name) + + async def _do_create_pr(self, task: Any, _agent: Any) -> Any: + from roboco.services.gateway.merge_chain import parent_branch_for + + parent = parent_branch_for(task.branch_name) + return await self.git_service.create_pr( + task.branch_name, parent=parent, is_root_pr=False + ) + + async def _do_pr_merge(self, task: Any, agent: Any) -> Any: + from roboco.services.gateway.merge_chain import parent_branch_for + + target = parent_branch_for(task.branch_name) + return await self.git_service.pr_merge( + task.pr_number, target=target, actor_agent_id=agent.id + ) + + @classmethod + def _side_effect_handlers(cls) -> dict[str, _SideEffectHandler]: + return { + "push_branch": cls._do_push_branch, + "create_pr": cls._do_create_pr, + "pr_merge": cls._do_pr_merge, + } diff --git a/roboco/services/gateway/choreographer/doc.py b/roboco/services/gateway/choreographer/doc.py index 732ceae0..ea4ab3c8 100644 --- a/roboco/services/gateway/choreographer/doc.py +++ b/roboco/services/gateway/choreographer/doc.py @@ -3,13 +3,40 @@ Mixin for ``claim_doc_task`` and ``i_documented``. Inherits typed helpers via ``ChoreographerHelpers`` under ``TYPE_CHECKING``; runtime class is the composed ``Choreographer``. + +Tasks 22 (lifecycle canonical spec): both verbs route their role/state +gate through ``spec.can_invoke_intent``. The verb-specific helpers +(``_verify_doc_owner``, ``_check_doc_gates``) STAY — they encode the +notes-length / files-list / journal:reflect gates the spec doesn't +model. The self-review block on ``docs_complete`` lives at the atomic- +action layer (``_ATOMIC_ACTIONS["docs_complete"].self_review_block=True``) +and naturally fires when the verb body builds a Context with +``actor_slug == original_developer_slug``; no verb-body retrofits +needed. + +P2 Task 10: ``_check_doc_gates`` delegates the actual requirement +checking to ``foundation.policy.tracing.check_requirements`` — the +verb→required-set mapping lives in ``VERB_REQUIREMENTS`` (single +source of truth). The hint translation lives in the shared +``_build_tracing_gap`` on Choreographer. + +``claim_doc_task`` composes ``("claim", "start")`` in the spec, but +the runtime semantic is "documenter inspects, status stays at +awaiting_documentation". The verb body therefore owns dispatch via +``task.doc_claim`` (mirroring ``claim_review``'s pattern in qa.py) so +the subsequent ``i_documented`` call still finds the task in +awaiting_documentation as the spec's ``docs_complete`` source-status +requires. """ from __future__ import annotations +from types import SimpleNamespace from typing import TYPE_CHECKING, Any from roboco.config import settings +from roboco.foundation.policy import lifecycle as spec_module +from roboco.foundation.policy import tracing as _tr from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.evidence_builder import build_evidence_for_task @@ -23,11 +50,86 @@ else: _Base = object +def _extract_original_developer(task: Any) -> str | None: + """Pull the original_developer slug out of a task's quick_context, if any. + + Mirrors ``_impl._extract_original_developer``. Lives here too so + the Doc mixin doesn't depend on ``_impl``'s module-level helper — + the spec's self-review block reads ``ctx.original_developer_slug`` + and the mixin builds the Context. + """ + qc = getattr(task, "quick_context", None) or "" + marker = "original_developer:" + if marker not in qc: + return None + tail = qc.split(marker, 1)[1].strip() + if not tail: + return None + return tail.split()[0] or None + + class DocMixin(_Base): """Documenter-role verbs.""" + async def _claim_doc_task_spec_gate( + self, doc_agent_id: UUID, task_id: UUID, t: Any + ) -> tuple[Envelope | None, Any, str, dict[str, Any]]: + """Run the spec gate for claim_doc_task. + + Returns (rejection, agent, role_str, briefing). Builds the + Context with ``actor_slug`` + ``original_developer_slug`` so the + spec evaluates self-review correctly even though ``claim`` + doesn't block self-review (the documenter-claims-own-doc-task + case is a non-issue at claim time but the field is set anyway + for downstream actions). + """ + agent = await self.task.agent_for(doc_agent_id) + role_str = str(agent.role) if agent is not None else "documenter" + briefing = await self._briefing_for(doc_agent_id, task_id) + try: + role = spec_module.Role(role_str) + except ValueError: + rejection = await self._emit_rejection( + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=doc_agent_id, + task_id=task_id, + verb="claim_doc_task", + ) + return rejection, agent, role_str, briefing + spec_ctx = spec_module.Context( + actor_id=doc_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + ) + decision = spec_module.can_invoke_intent(role, "claim_doc_task", t, spec_ctx) + if not decision.allowed: + rejection = await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), + agent_id=doc_agent_id, + task_id=task_id, + verb="claim_doc_task", + ) + return rejection, agent, role_str, briefing + return None, agent, role_str, briefing + async def claim_doc_task(self, doc_agent_id: UUID, task_id: UUID) -> Envelope: - """Documenter claims task in awaiting_documentation; returns evidence inline.""" + """Documenter claims task in awaiting_documentation; returns evidence inline. + + Spec gate runs first and enforces role membership (documenter + only) plus the composed ``claim`` action's source-status + constraint (AWAITING_DOCUMENTATION is one of the allowed + sources). After the gate accepts, the verb body owns dispatch + via ``task.doc_claim`` (specialized claim that keeps status at + AWAITING_DOCUMENTATION so the downstream ``docs_complete`` + source-status requirement still matches). The behavioral claim + guards (already_active / paused) still run after the spec gate. + """ t = await self.task.get(task_id) if t is None: return await self._emit_rejection( @@ -36,66 +138,59 @@ class DocMixin(_Base): task_id=task_id, verb="claim_doc_task", ) - if str(t.status) != "awaiting_documentation": - return await self._emit_rejection( - Envelope.invalid_state( - message=( - f"task {task_id} is in {t.status}, " - "expected awaiting_documentation" - ), - remediate="call give_me_work() to find an actionable doc task", - context_briefing=await self._briefing_for(doc_agent_id, task_id), - ).with_introspection(task=t, role="documenter"), - agent_id=doc_agent_id, - task_id=task_id, - verb="claim_doc_task", - ) + ( + spec_rejection, + _agent, + role_str, + briefing, + ) = await self._claim_doc_task_spec_gate(doc_agent_id, task_id, t) + if spec_rejection is not None: + return spec_rejection guard = await self._run_claim_guards( agent_id=doc_agent_id, task=t, - skip_role_typed=True, - skip_pm_code=True, skip_sequence=True, ) if guard: - guard.with_introspection(task=t, role="documenter") + guard.with_introspection(task=t, role=role_str) return await self._emit_rejection( - self._with_briefing( - guard, - await self._briefing_for(doc_agent_id, task_id), - ), + self._with_briefing(guard, briefing), agent_id=doc_agent_id, task_id=task_id, verb="claim_doc_task", ) + # Verb body owns dispatch — claim_doc_task's spec composes=("claim", + # "start") but doc_claim is the runtime-correct specialized form + # that keeps status at AWAITING_DOCUMENTATION. See module docstring. t = await self.task.doc_claim(doc_agent_id, task_id) - files_changed: list[str] = [] - if t.work_session_id: - files_changed = await self.work_session.files_changed(t.work_session_id) - diff = "" - if t.branch_name: - diff = await self.git.diff(branch_name=t.branch_name) - journal_highlights = await self.evidence_repo.journal_highlights_for_task( - task_id - ) - ev = build_evidence_for_task( - t, - journal_highlights=journal_highlights, - files_changed=files_changed, - pr_diff_summary=diff, - ) + ev = await self._claim_doc_evidence(t, task_id) return Envelope.ok( status=str(t.status), task_id=str(task_id), - next=( - "write docs in your workspace, commit them, then call " - "i_documented(task_id, notes, files)" - ), - evidence=ev.as_dict(), - context_briefing=await self._briefing_for(doc_agent_id, task_id), - ).with_introspection(task=t, role="documenter") + next=spec_module._INTENT_VERBS["claim_doc_task"].next_hint(t), + evidence=ev, + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + + async def _claim_doc_evidence(self, task: Any, task_id: UUID) -> dict[str, Any]: + """Build the evidence dict surfaced inline on claim_doc_task ok envelopes.""" + files_changed: list[str] = [] + if task.work_session_id: + files_changed = await self.work_session.files_changed(task.work_session_id) + diff = "" + if task.branch_name: + diff = await self.git.diff(branch_name=task.branch_name) + journal_highlights = await self.evidence_repo.journal_highlights_for_task( + task_id + ) + return build_evidence_for_task( + task, + journal_highlights=journal_highlights, + files_changed=files_changed, + pr_diff_summary=diff, + ).as_dict() async def _verify_doc_owner( self, doc_agent_id: UUID, task_id: UUID @@ -122,7 +217,7 @@ class DocMixin(_Base): ), None return None, t - async def _check_i_documented_inputs( + async def _check_doc_gates( self, doc_agent_id: UUID, task_id: UUID, @@ -130,37 +225,98 @@ class DocMixin(_Base): files: list[str], task: Any, ) -> Envelope | None: - """Validate notes length + files non-empty. Returns rejection or None.""" - if not notes or len(notes) < settings.docs_notes_min_chars: - return await self._emit_rejection( - Envelope.tracing_gap( - missing=["docs_notes>=20"], - remediate=( - "i_documented requires notes>=20 chars summarizing what you " - "documented and where (file paths)." - " Include each file in `files=...`." - ), - context_briefing=await self._briefing_for(doc_agent_id, task_id), - ).with_introspection(task=task, role="documenter"), + """i_documented field + journal gates via foundation.policy.tracing. + + Returns rejection envelope or None on pass. The required-set for + ``i_documented`` (DOCS_FILES_NON_EMPTY + DOCS_NOTES_MIN_CHARS + + JOURNAL_REFLECT) lives in ``VERB_REQUIREMENTS``. + + The verb's ``notes`` argument and ``files`` list haven't been + persisted to the task yet (the spec runner / verb body writes + them via the atomic action / pre-dispatch stamp), so we thread + them through a SimpleNamespace shim with the minimal attributes + the foundation checkers read off the task object (dev_notes + + documents — see foundation.policy.tracing._check_docs_notes_min_chars + and _check_docs_files_non_empty). + """ + has_reflect = await self.journal.has_reflect_for_task(doc_agent_id, task_id) + task_view = SimpleNamespace( + dev_notes=notes, + documents=list(files), + ) + ctx = _tr.GateContext( + journal_reflect_present=has_reflect, + docs_notes_min_chars=settings.docs_notes_min_chars, + ) + result = _tr.check_requirements( + task=task_view, + requirements=list(_tr.requirements_for("i_documented")), + ctx=ctx, + ) + if result.passed: + return None + return await self._emit_rejection( + ( + await self._build_tracing_gap(doc_agent_id, task_id, result.missing) + ).with_introspection(task=task, role="documenter"), + agent_id=doc_agent_id, + task_id=task_id, + verb="i_documented", + ) + + async def _i_documented_spec_gate( + self, + doc_agent_id: UUID, + task_id: UUID, + owned_task: Any, + notes: str, + files: list[str], + ) -> tuple[Envelope | None, Any, str, spec_module.Context, dict[str, Any]]: + """Run the spec gate for i_documented. + + Returns (rejection, agent, role_str, spec_ctx, briefing). Builds + the Context with ``actor_slug`` + ``original_developer_slug`` so + the docs_complete action's ``self_review_block=True`` fires when + the documenter is the original developer. + """ + agent = await self.task.agent_for(doc_agent_id) + role_str = str(agent.role) if agent is not None else "documenter" + briefing = await self._briefing_for(doc_agent_id, task_id) + try: + role = spec_module.Role(role_str) + except ValueError: + rejection = await self._emit_rejection( + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=owned_task, role=role_str), agent_id=doc_agent_id, task_id=task_id, verb="i_documented", ) - if not files: - return await self._emit_rejection( - Envelope.tracing_gap( - missing=["files"], - remediate=( - "i_documented requires files=['', ...]" - " listing the doc files written." - ), - context_briefing=await self._briefing_for(doc_agent_id, task_id), - ).with_introspection(task=task, role="documenter"), + return rejection, agent, role_str, spec_module.Context(), briefing + spec_ctx = spec_module.Context( + actor_id=doc_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(owned_task), + notes=notes, + files=tuple(files), + ) + decision = spec_module.can_invoke_intent( + role, "i_documented", owned_task, spec_ctx + ) + if not decision.allowed: + rejection = await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=owned_task, role=role_str + ), agent_id=doc_agent_id, task_id=task_id, verb="i_documented", ) - return None + return rejection, agent, role_str, spec_ctx, briefing + return None, agent, role_str, spec_ctx, briefing async def i_documented( self, @@ -172,37 +328,88 @@ class DocMixin(_Base): """Documenter signals docs complete. Transitions awaiting_documentation → awaiting_pm_review. + + Spec gate runs first and enforces role membership (documenter) + plus the composed ``docs_complete`` action's source-status + (AWAITING_DOCUMENTATION), task_type, and self-review block (the + atomic action's ``self_review_block=True`` rejects when the + documenter is the original developer of the task). After the + spec gate accepts, the verb-specific gates stay (ownership via + ``_verify_doc_owner`` and field + journal gates via + ``_check_doc_gates``); none are modelled by the spec. + Files are stamped onto ``task.documents`` before the runner + dispatches ``docs_complete`` so the indexer sees them, then the + verb body reassigns to the cell PM for handoff. """ rejection, owned_task = await self._verify_doc_owner(doc_agent_id, task_id) if rejection is not None: return rejection - input_rejection = await self._check_i_documented_inputs( + ( + spec_rejection, + agent, + role_str, + spec_ctx, + briefing, + ) = await self._i_documented_spec_gate( + doc_agent_id, task_id, owned_task, notes, files + ) + if spec_rejection is not None: + return spec_rejection + + gate_rejection = await self._check_doc_gates( doc_agent_id, task_id, notes, files, owned_task ) - if input_rejection is not None: - return input_rejection + if gate_rejection is not None: + return gate_rejection # TaskService.docs_complete signature is (task_id, doc_notes); it # reads task.documents for indexing. Stamp the file list onto the - # task before the transition so the indexer sees it. + # task before the runner dispatches docs_complete so the indexer + # sees it. existing = await self.task.get(task_id) if existing is not None: existing.documents = files await self.task.session.flush() - t = await self.task.docs_complete(task_id, doc_notes=notes) - pm_agent = await self.task.cell_pm_for_team(t.team) - if pm_agent is not None: - await self.task.reassign(task_id, pm_agent.id) - await self.a2a.send( - from_agent=doc_agent_id, - to_agent=pm_agent.id, - skill="task_management", + + runner = self._verb_runner() + try: + t = await runner.run_intent("i_documented", owned_task, agent, spec_ctx) + except Exception as exc: + return await self._emit_rejection( + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=owned_task, role=role_str), + agent_id=doc_agent_id, task_id=task_id, - body=f"Docs complete for {t.id}. Ready for PM review + merge.", + verb="i_documented", ) + + await self._handoff_to_cell_pm(doc_agent_id, task_id, t) return Envelope.ok( status=str(t.status), task_id=str(task_id), - next="idle until PM completes", - context_briefing=await self._briefing_for(doc_agent_id, task_id), - ).with_introspection(task=t, role="documenter") + next=spec_module._INTENT_VERBS["i_documented"].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + + async def _handoff_to_cell_pm( + self, doc_agent_id: UUID, task_id: UUID, task: Any + ) -> None: + """Reassign + a2a-notify the cell PM after docs_complete dispatch. + + Side effect outside the spec; lives here so ``i_documented``'s + body stays under the cyclomatic-complexity ceiling. + """ + pm_agent = await self.task.cell_pm_for_team(task.team) + if pm_agent is None: + return + await self.task.reassign(task_id, pm_agent.id) + await self.a2a.send( + from_agent=doc_agent_id, + to_agent=pm_agent.id, + skill="task_management", + task_id=task_id, + body=f"Docs complete for {task.id}. Ready for PM review + merge.", + ) diff --git a/roboco/services/gateway/choreographer/qa.py b/roboco/services/gateway/choreographer/qa.py index b43c569f..ea4effb3 100644 --- a/roboco/services/gateway/choreographer/qa.py +++ b/roboco/services/gateway/choreographer/qa.py @@ -1,20 +1,47 @@ """QA verbs (audit P2-2 third per-role split). Mixin for ``claim_review``, ``pass_review``, ``fail_review`` and the -two QA-specific helpers ``_check_qa_pass_gates`` / ``_qa_tracing_gap``. -Helpers stay together with the verbs that use them — they're not used -by any other role. +verb-specific helper ``_qa_pass_gate_check``. The helper stays with +the verbs that use it — it's not used by any other role. Inherits from ``ChoreographerHelpers`` under ``TYPE_CHECKING`` only so mypy resolves ``self.task`` etc. as typed; at runtime the composed ``Choreographer`` supplies the real attributes via MRO. + +Tasks 21 (lifecycle canonical spec): all three verbs now route their +role/state gate through ``spec.can_invoke_intent``. The verb-specific +helpers (``_verify_qa_owner``, ``_qa_pass_gate_check``) STAY — they +encode notes-length / journal:learning / qa_evidence_inspected gates +the spec doesn't model. The self-review block lives at the atomic- +action layer (``_ATOMIC_ACTIONS["qa_pass" | "qa_fail"] +.self_review_block=True``) and naturally fires when the verb body +builds a Context with ``actor_slug == original_developer_slug``; no +verb-body retrofits needed. + +P2 Task 9: ``_qa_pass_gate_check`` delegates the actual requirement +checking to ``foundation.policy.tracing.check_requirements`` — the +verb→required-set mapping lives in ``VERB_REQUIREMENTS`` (single +source of truth). The hint translation lives in the shared +``_build_tracing_gap`` on Choreographer. + +``claim_review`` composes ``("claim", "start")`` in the spec, but the +runtime semantic is "QA inspects, status stays at awaiting_qa". The +verb body therefore owns dispatch via ``task.qa_claim`` (mirroring the +``escalate_up`` empty-composes pattern) so subsequent ``pass_review`` +/ ``fail_review`` calls still find the task in awaiting_qa as the +spec's ``qa_pass`` / ``qa_fail`` source-status requires. The spec gate +still validates role + claim source-status + task_type before dispatch. """ from __future__ import annotations +from dataclasses import dataclass, field +from types import SimpleNamespace from typing import TYPE_CHECKING, Any from roboco.config import settings +from roboco.foundation.policy import lifecycle as spec_module +from roboco.foundation.policy import tracing as _tr from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.evidence_builder import build_evidence_for_task @@ -28,15 +55,59 @@ else: _Base = object +@dataclass(frozen=True) +class _QASpecGateInputs: + """Inputs for ``_qa_review_spec_gate`` bundled to keep PLR0913 at bay. + + Frozen so the helper sites can't mutate caller state. + """ + + qa_agent_id: Any + task_id: Any + task: Any + verb: str + notes: str + issues: tuple[str, ...] = field(default_factory=tuple) + + +def _extract_original_developer(task: Any) -> str | None: + """Pull the original_developer slug out of a task's quick_context, if any. + + Mirrors ``_impl._extract_original_developer``. Lives here too so + QA / Doc mixins don't depend on ``_impl``'s module-level helper — + the spec's self-review block reads ``ctx.original_developer_slug`` + and the mixins build the Context. + """ + qc = getattr(task, "quick_context", None) or "" + marker = "original_developer:" + if marker not in qc: + return None + tail = qc.split(marker, 1)[1].strip() + if not tail: + return None + return tail.split()[0] or None + + class QAMixin(_Base): """QA-role verbs.""" async def claim_review(self, qa_agent_id: UUID, task_id: UUID) -> Envelope: """QA agent claims task in awaiting_qa for review. - The response includes evidence (pr_url, pr_number, commits, files_changed, - journal_highlights, acceptance_criteria_status) INLINE so the QA agent - cannot miss the PR data. Marks `qa_evidence_inspected=true` automatically. + Spec gate runs first and enforces role membership (qa only) plus + the composed ``claim`` action's source-status constraint + (AWAITING_QA is one of the allowed sources). After the gate + accepts, the verb body owns dispatch via ``task.qa_claim`` + (specialized claim that keeps status at AWAITING_QA so the + downstream ``qa_pass`` / ``qa_fail`` source-status requirement + still matches). The behavioral claim guards (already_active / + paused) still run after the spec gate — they encode concurrency + invariants the spec doesn't model. + + The response includes evidence (pr_url, pr_number, commits, + files_changed, journal_highlights, acceptance_criteria_status) + INLINE so the QA agent cannot miss the PR data. Marks + ``qa_evidence_inspected=true`` automatically. """ t = await self.task.get(task_id) if t is None: @@ -46,16 +117,33 @@ class QAMixin(_Base): task_id=task_id, verb="claim_review", ) - if str(t.status) != "awaiting_qa": + agent = await self.task.agent_for(qa_agent_id) + role_str = str(agent.role) if agent is not None else "qa" + briefing = await self._briefing_for(qa_agent_id, task_id) + try: + role = spec_module.Role(role_str) + except ValueError: return await self._emit_rejection( - Envelope.invalid_state( - message=( - f"task {task_id} is in {t.status}, " - "expected awaiting_qa for review" - ), - remediate="call give_me_work() to find an actionable QA task", - context_briefing=await self._briefing_for(qa_agent_id, task_id), - ).with_introspection(task=t, role="qa"), + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=qa_agent_id, + task_id=task_id, + verb="claim_review", + ) + spec_ctx = spec_module.Context( + actor_id=qa_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + ) + decision = spec_module.can_invoke_intent(role, "claim_review", t, spec_ctx) + if not decision.allowed: + return await self._emit_rejection( + Envelope.from_decision(decision, briefing=briefing).with_introspection( + task=t, role=role_str + ), agent_id=qa_agent_id, task_id=task_id, verb="claim_review", @@ -64,25 +152,40 @@ class QAMixin(_Base): guard = await self._run_claim_guards( agent_id=qa_agent_id, task=t, - skip_role_typed=True, - skip_pm_code=True, skip_sequence=True, ) if guard: - guard.with_introspection(task=t, role="qa") + guard.with_introspection(task=t, role=role_str) return await self._emit_rejection( - self._with_briefing( - guard, - await self._briefing_for(qa_agent_id, task_id), - ), + self._with_briefing(guard, briefing), agent_id=qa_agent_id, task_id=task_id, verb="claim_review", ) + # Verb body owns dispatch — claim_review's spec composes=("claim", + # "start") but qa_claim is the runtime-correct specialized form + # that keeps status at AWAITING_QA so qa_pass / qa_fail's source- + # status requirement matches downstream. See module docstring. t = await self.task.qa_claim(qa_agent_id, task_id) await self.task.mark_evidence_inspected(task_id) + ev = await self._build_qa_claim_evidence(t, task_id) + return Envelope.ok( + status=str(t.status), + task_id=str(task_id), + next=spec_module._INTENT_VERBS["claim_review"].next_hint(t), + evidence=ev.as_dict(), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) + + async def _build_qa_claim_evidence(self, t: Any, task_id: UUID) -> Any: + """Assemble the inline evidence payload returned by claim_review. + + Bundles files_changed (from work_session) + pr_diff_summary (from + git) + journal_highlights so the QA agent has the full PR + context up-front and can't miss a piece. + """ files_changed: list[str] = [] if t.work_session_id: files_changed = await self.work_session.files_changed(t.work_session_id) @@ -92,22 +195,12 @@ class QAMixin(_Base): journal_highlights = await self.evidence_repo.journal_highlights_for_task( task_id ) - ev = build_evidence_for_task( + return build_evidence_for_task( t, journal_highlights=journal_highlights, files_changed=files_changed, pr_diff_summary=diff_summary, ) - return Envelope.ok( - status=str(t.status), - task_id=str(task_id), - next=( - "review the diff. Then call pass(notes) to accept or " - "fail(issues) to request changes." - ), - evidence=ev.as_dict(), - context_briefing=await self._briefing_for(qa_agent_id, task_id), - ).with_introspection(task=t, role="qa") async def _verify_qa_owner( self, qa_agent_id: UUID, task_id: UUID, verb: str @@ -137,40 +230,162 @@ class QAMixin(_Base): async def _qa_pass_gate_check( self, qa_agent_id: UUID, task_id: UUID, notes: str, t: Any, verb: str ) -> Envelope | None: - """QA pass-gate evaluation. Returns rejection envelope or None on pass.""" + """QA pass/fail-gate evaluation via foundation.policy.tracing. + + Returns rejection envelope or None on pass. The required-set + for ``pass_review`` and ``fail_review`` is identical (both need + QA_NOTES_MIN_CHARS + QA_EVIDENCE_INSPECTED + JOURNAL_LEARNING), + so a single helper handles both — the caller just passes the + verb name through for VERB_REQUIREMENTS lookup. + + The verb's ``notes`` argument hasn't been persisted to the task + yet (the spec runner writes it via the atomic action), so we + thread it through a SimpleNamespace shim with the minimal + attributes the foundation checkers read off the task object + (qa_notes + qa_evidence_inspected — see + foundation.policy.tracing._check_qa_notes_min_chars and + _check_qa_evidence_inspected). + """ has_learning = await self.journal.has_learning_for_task(qa_agent_id, task_id) - missing = self._check_qa_pass_gates( - notes=notes, - has_learning=has_learning, - evidence_inspected=t.qa_evidence_inspected, + task_view = SimpleNamespace( + qa_notes=notes, + qa_evidence_inspected=getattr(t, "qa_evidence_inspected", False), ) - if not missing: + ctx = _tr.GateContext( + journal_learning_present=has_learning, + qa_notes_min_chars=settings.qa_notes_min_chars, + ) + result = _tr.check_requirements( + task=task_view, + requirements=list(_tr.requirements_for(verb)), + ctx=ctx, + ) + if result.passed: return None return await self._emit_rejection( - self._qa_tracing_gap( - missing, - task_id, - await self._briefing_for(qa_agent_id, task_id), + ( + await self._build_tracing_gap(qa_agent_id, task_id, result.missing) ).with_introspection(task=t, role="qa"), agent_id=qa_agent_id, task_id=task_id, verb=verb, ) + async def _qa_review_spec_gate( + self, inputs: _QASpecGateInputs + ) -> tuple[Envelope | None, Any, str]: + """Run the spec gate for pass_review / fail_review. + + Returns (rejection, agent, role_str). Builds the Context with + ``actor_slug`` + ``original_developer_slug`` so the underlying + atomic action's ``self_review_block=True`` fires when the QA + agent is the original developer of the task. + """ + qa_agent_id = inputs.qa_agent_id + task_id = inputs.task_id + t = inputs.task + verb = inputs.verb + agent = await self.task.agent_for(qa_agent_id) + role_str = str(agent.role) if agent is not None else "qa" + briefing = await self._briefing_for(qa_agent_id, task_id) + try: + role = spec_module.Role(role_str) + except ValueError: + return ( + await self._emit_rejection( + Envelope.not_authorized( + message=f"unknown role '{role_str}'", + remediate="role is not declared in the lifecycle spec", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=qa_agent_id, + task_id=task_id, + verb=verb, + ), + agent, + role_str, + ) + spec_ctx = spec_module.Context( + actor_id=qa_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + notes=inputs.notes, + issues=inputs.issues, + ) + decision = spec_module.can_invoke_intent(role, verb, t, spec_ctx) + if not decision.allowed: + return ( + await self._emit_rejection( + Envelope.from_decision( + decision, briefing=briefing + ).with_introspection(task=t, role=role_str), + agent_id=qa_agent_id, + task_id=task_id, + verb=verb, + ), + agent, + role_str, + ) + return None, agent, role_str + async def pass_review( self, qa_agent_id: UUID, task_id: UUID, notes: str ) -> Envelope: - """QA passes the task; transitions awaiting_qa → awaiting_documentation.""" + """QA passes the task; transitions awaiting_qa → awaiting_documentation. + + Spec gate runs first and enforces role membership (qa) plus the + composed ``qa_pass`` action's source-status (AWAITING_QA), + task_type, and self-review block (the atomic action's + ``self_review_block=True`` rejects when the QA actor's slug + matches the original developer's). After the spec gate accepts, + the verb-specific gates stay (ownership via ``_verify_qa_owner`` + and notes-length / journal:learning / qa_evidence_inspected via + ``_qa_pass_gate_check``); none of those are modelled by the spec. + The composed atomic ``qa_pass`` is then dispatched through + ``VerbRunner.run_intent``, after which the verb body reassigns + the documenter for handoff. + """ rejection, t = await self._verify_qa_owner(qa_agent_id, task_id, "pass_review") if rejection is not None: return rejection + spec_rejection, agent, role_str = await self._qa_review_spec_gate( + _QASpecGateInputs( + qa_agent_id=qa_agent_id, + task_id=task_id, + task=t, + verb="pass_review", + notes=notes, + ) + ) + if spec_rejection is not None: + return spec_rejection gate_rejection = await self._qa_pass_gate_check( qa_agent_id, task_id, notes, t, "pass_review" ) if gate_rejection is not None: return gate_rejection - t = await self.task.qa_pass(qa_agent_id, task_id, notes) + briefing = await self._briefing_for(qa_agent_id, task_id) + spec_ctx = spec_module.Context( + actor_id=qa_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + notes=notes, + ) + runner = self._verb_runner() + try: + t = await runner.run_intent("pass_review", t, agent, spec_ctx) + except Exception as exc: + return await self._emit_rejection( + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=qa_agent_id, + task_id=task_id, + verb="pass_review", + ) doc_agent = await self.task.documenter_for_team(t.team) if doc_agent is not None: @@ -185,53 +400,25 @@ class QAMixin(_Base): return Envelope.ok( status=str(t.status), task_id=str(task_id), - next="idle until next QA work arrives", - context_briefing=await self._briefing_for(qa_agent_id, task_id), - ).with_introspection(task=t, role="qa") - - @staticmethod - def _check_qa_pass_gates( - *, notes: str, has_learning: bool, evidence_inspected: bool - ) -> list[str]: - """Return list of missing gate keys; empty list if all pass.""" - missing: list[str] = [] - if not notes or len(notes) < settings.qa_notes_min_chars: - missing.append("qa_notes>=min") - if not has_learning: - missing.append("journal:learning") - if not evidence_inspected: - missing.append("qa_evidence_inspected") - return missing - - @staticmethod - def _qa_tracing_gap( - missing: list[str], task_id: UUID, briefing: dict[str, Any] - ) -> Envelope: - """Build a tracing_gap envelope with role-appropriate hints.""" - from roboco.services.gateway.remediation import ( - hint_for_evidence_not_inspected, - hint_for_missing_journal_learning, - hint_for_missing_qa_notes, - ) - - hint_map = { - "qa_notes>=min": hint_for_missing_qa_notes(), - "journal:learning": hint_for_missing_journal_learning(), - "qa_evidence_inspected": hint_for_evidence_not_inspected( - task_id=str(task_id) - ), - } - hints = [hint_map[m] for m in missing if m in hint_map] - return Envelope.tracing_gap( - missing=missing, - remediate=" ; ".join(hints), + next=spec_module._INTENT_VERBS["pass_review"].next_hint(t), context_briefing=briefing, - ) + ).with_introspection(task=t, role=role_str) async def fail_review( self, qa_agent_id: UUID, task_id: UUID, issues: list[str] ) -> Envelope: - """QA fails the task with concrete issues; transitions to needs_revision.""" + """QA fails the task with concrete issues; transitions to needs_revision. + + Spec gate runs first and enforces role membership (qa) plus the + composed ``qa_fail`` action's source-status (AWAITING_QA), + task_type, and self-review block (same shape as ``pass_review``). + After the spec gate accepts, the verb-specific gates stay + (ownership via ``_verify_qa_owner``, ``issues`` non-empty, and + notes-length / journal:learning / qa_evidence_inspected via + ``_qa_pass_gate_check``). The composed atomic ``qa_fail`` is + dispatched through ``VerbRunner.run_intent``, then the verb body + notifies the original developer via a2a. + """ rejection, t = await self._verify_qa_owner(qa_agent_id, task_id, "fail_review") if rejection is not None: return rejection @@ -248,13 +435,47 @@ class QAMixin(_Base): ) notes = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues) + spec_rejection, agent, role_str = await self._qa_review_spec_gate( + _QASpecGateInputs( + qa_agent_id=qa_agent_id, + task_id=task_id, + task=t, + verb="fail_review", + notes=notes, + issues=tuple(issues), + ) + ) + if spec_rejection is not None: + return spec_rejection gate_rejection = await self._qa_pass_gate_check( qa_agent_id, task_id, notes, t, "fail_review" ) if gate_rejection is not None: return gate_rejection - t = await self.task.qa_fail(qa_agent_id, task_id, notes, issues) + briefing = await self._briefing_for(qa_agent_id, task_id) + spec_ctx = spec_module.Context( + actor_id=qa_agent_id, + actor_slug=getattr(agent, "slug", None) if agent is not None else None, + original_developer_slug=_extract_original_developer(t), + notes=notes, + issues=tuple(issues), + ) + runner = self._verb_runner() + try: + t = await runner.run_intent("fail_review", t, agent, spec_ctx) + except Exception as exc: + return await self._emit_rejection( + Envelope.invalid_state( + message=f"verb runner failed: {exc}", + remediate="check workspace + retry; if persistent, escalate", + context_briefing=briefing, + ).with_introspection(task=t, role=role_str), + agent_id=qa_agent_id, + task_id=task_id, + verb="fail_review", + ) + if t.assigned_to is not None: await self.a2a.send( from_agent=qa_agent_id, @@ -266,6 +487,6 @@ class QAMixin(_Base): return Envelope.ok( status=str(t.status), task_id=str(task_id), - next="idle — dev will revise and re-submit", - context_briefing=await self._briefing_for(qa_agent_id, task_id), - ).with_introspection(task=t, role="qa") + next=spec_module._INTENT_VERBS["fail_review"].next_hint(t), + context_briefing=briefing, + ).with_introspection(task=t, role=role_str) diff --git a/roboco/services/gateway/claim_guards.py b/roboco/services/gateway/claim_guards.py index 46244881..3c6a213f 100644 --- a/roboco/services/gateway/claim_guards.py +++ b/roboco/services/gateway/claim_guards.py @@ -1,10 +1,16 @@ -"""Claim-time predicates restored from pre-gateway _helpers.py:124-204. +"""Concurrency-invariant claim-time predicates. These guards run BEFORE any task-status mutation in the claim verbs (``i_will_work_on``, ``i_will_plan``, ``claim_review``, ``claim_doc_task``). Each predicate returns a rejection ``Envelope`` if it fires; ``None`` if it passes. The first non-None return short-circuits the claim. +Scope: only system-level concurrency invariants the lifecycle spec does +NOT model live here. Role/state/task_type checks (the former +``role_typed_claim_guard`` and ``pm_cannot_execute_code_guard``) now route +through ``spec.can_invoke_action``'s CLAIM_RULES + ``ActionSpec +.allowed_task_types`` and have been deleted (Task 27, 2026-05-10). + Pre-gateway location at commit 0c3d15a: roboco/mcp/tasks/handlers/_helpers.py:124-204 roboco/mcp/tasks/handlers/claim.py:121-180 (sibling sequence) @@ -19,9 +25,6 @@ from roboco.services.gateway.envelope import Envelope if TYPE_CHECKING: from uuid import UUID -# Roles that may NOT claim a code task — pre-gateway _helpers.py:181-204. -_PM_ROLES: frozenset[str] = frozenset({"cell_pm", "main_pm"}) - # Statuses that count as "still actively worked" — pre-gateway # _helpers.py:check_blocking_tasks 134-152. _ACTIVE_BLOCKING_STATUSES: frozenset[str] = frozenset( @@ -32,15 +35,6 @@ _ACTIVE_BLOCKING_STATUSES: frozenset[str] = frozenset( # pre-gateway claim.py:153. _TERMINAL_STATUSES: frozenset[str] = frozenset({"completed", "cancelled"}) -# Allowed task_types per role for the claim verb. Mirrors the role-typed -# claim policy: developers do code-like work; QA reviews; documenters -# document. PMs cannot claim code (see pm_cannot_execute_code). -_ROLE_TASK_TYPE_ALLOW: dict[str, frozenset[str]] = { - "developer": frozenset({"code", "research", "design"}), - "qa": frozenset(), # QA never enters via i_will_work_on - "documenter": frozenset(), # Doc never enters via i_will_work_on -} - def already_active_guard( in_progress_tasks: list[Any], target_task_id: UUID @@ -87,56 +81,18 @@ def paused_tasks_guard(paused_tasks: list[Any]) -> Envelope | None: ) -def pm_cannot_execute_code_guard(role: str, task_type: str) -> Envelope | None: - """Refuse cell_pm/main_pm from claiming a code task. - - Pre-gateway: _helpers.py:_guard_pm_from_code_tasks 181-204. - """ - if role not in _PM_ROLES: - return None - if task_type != "code": - return None - nice_role = role.replace("_", " ").title() - return Envelope.not_authorized( - message=( - f"{nice_role} cannot claim code tasks. PMs coordinate, never execute code." - ), - remediate=( - "PMs coordinate, never execute code. Delegate this to a " - "developer in your cell via delegate(parent_task_id, " - "title=..., description=..., assigned_to='be-dev-1', " - "team='backend')." - ), - ) - - -def role_typed_claim_guard(role: str, task_type: str) -> Envelope | None: - """Refuse cross-role claim attempts (developer claiming doc/qa, etc). - - Pre-gateway: _helpers.py:_CLAIMABLE_STATUSES + the per-role status mapping - at lines 144-150 plus the implicit task_type cohesion. The pre-gateway - code routed by status; here we route by ``task_type`` because the verbs - already split by status (claim_review, claim_doc_task vs i_will_work_on). - - Only runs for non-PM roles; PMs route through pm_cannot_execute_code_guard - and i_will_plan instead. - """ - if role in _PM_ROLES: - return None - if role not in _ROLE_TASK_TYPE_ALLOW: - # Unknown roles default to developer-like — silently allowed; the - # service-layer enforcement catches misuse downstream. - return None - allowed = _ROLE_TASK_TYPE_ALLOW[role] - if task_type in allowed: - return None - return Envelope.not_authorized( - message=(f"role {role!r} cannot claim a {task_type!r} task via i_will_work_on"), - remediate=( - "developer claims code/research/design; qa uses claim_review; " - "documenter uses claim_doc_task" - ), - ) +def _earlier_blocking_sibling( + target_task: Any, siblings: list[Any], my_sequence: int +) -> Any | None: + """Return the first non-terminal sibling with a lower sequence, else None.""" + for sib in siblings: + if sib.id == target_task.id: + continue + sib_seq = getattr(sib, "sequence", 0) or 0 + sib_status = str(getattr(sib, "status", "")) + if sib_seq < my_sequence and sib_status not in _TERMINAL_STATUSES: + return sib + return None def sibling_sequence_guard(target_task: Any, siblings: list[Any]) -> Envelope | None: @@ -154,20 +110,18 @@ def sibling_sequence_guard(target_task: Any, siblings: list[Any]) -> Envelope | my_sequence = getattr(target_task, "sequence", 0) or 0 if my_sequence == 0: return None - for sib in siblings: - if sib.id == target_task.id: - continue - sib_seq = getattr(sib, "sequence", 0) or 0 - sib_status = str(getattr(sib, "status", "")) - if sib_seq < my_sequence and sib_status not in _TERMINAL_STATUSES: - return Envelope.invalid_state( - message=( - f"sequence {my_sequence} blocked: earlier sibling " - f"{sib.id} (sequence {sib_seq}) is in {sib_status}" - ), - remediate=( - f"wait for sibling {sib.id} (sequence {sib_seq}) to " - "reach completed/cancelled before claiming this task" - ), - ) - return None + blocker = _earlier_blocking_sibling(target_task, siblings, my_sequence) + if blocker is None: + return None + sib_seq = getattr(blocker, "sequence", 0) or 0 + sib_status = str(getattr(blocker, "status", "")) + return Envelope.invalid_state( + message=( + f"sequence {my_sequence} blocked: earlier sibling " + f"{blocker.id} (sequence {sib_seq}) is in {sib_status}" + ), + remediate=( + f"wait for sibling {blocker.id} (sequence {sib_seq}) to " + "reach completed/cancelled before claiming this task" + ), + ) diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index 90d3552b..469e667d 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -15,20 +15,38 @@ import re from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from roboco.foundation.policy import communications as _comms +from roboco.foundation.policy.journaling import Scope as _Scope from roboco.services.gateway.commit_validator import validate_commit_message from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.evidence_builder import build_evidence_for_task -from roboco.services.gateway.verb_gates import is_verb_allowed if TYPE_CHECKING: from uuid import UUID -_VALID_NOTE_SCOPES: frozenset[str] = frozenset( - {"note", "decision", "reflect", "learning", "struggle"} -) +# Scope catalog is canonical in foundation.policy.journaling. +# Derived here as a string frozenset for the existing call sites that +# compare strings rather than the Scope enum. +_VALID_NOTE_SCOPES: frozenset[str] = frozenset(s.value for s in _Scope) _TASK_ID_PREFIX_RE = re.compile(r"^\s*\[[a-zA-Z0-9_-]+\]\s*") +# Content-tool RBAC. These are the same role sets that drive the spawn +# manifest in `role_config.py` (`_DEV_DO`/`_DOC_DO` include "commit"; +# `_CELL_PM_DO`/`_MAIN_PM_DO`/`_BOARD_DO` include "notify"). Pre-2026-05-10 +# this lookup went through `verb_gates.is_verb_allowed`; the verb-gates +# table has been folded into `roboco.foundation.policy.lifecycle`, but +# `commit` and `notify` are content tools (not lifecycle intents) so they +# live here as explicit role frozensets — not in `_INTENT_VERBS`. +# +# Notification sender + priority allowlists are canonical in +# foundation.policy.communications. Derived as string frozensets here so +# the existing call sites that compare strings keep working. +_COMMIT_ALLOWED_ROLES: frozenset[str] = frozenset({"developer", "documenter"}) +_NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset( + r.value for r in _comms.NOTIFY_SENDER_ROLES +) + def _ownership_violation(task_id: UUID) -> Envelope: """Standard envelope for Gate Set D ownership violations. @@ -60,24 +78,7 @@ class ContentActionsDeps: notifications: Any -# Notification authorization is sourced from verb_gates._ALWAYS_AVAILABLE -# (which lists `notify` for cell_pm, main_pm, product_owner, head_marketing). -# Pre-gateway this lived as a `_NOTIFY_ALLOWED_ROLES` constant here; merging -# into verb_gates removes the risk that the two sets disagree. -_VALID_NOTIFY_PRIORITIES: frozenset[str] = frozenset({"normal", "high", "urgent"}) - -# Synthetic task probe for role-only gate checks: when the verb body -# wants to fast-fail on role BEFORE loading the agent's active task, -# we hand verb_gates an in-progress code-typed shape so it consults -# the same _STATE_VERBS row a real in-progress task would. -class _RoleProbeTask: - """Minimal task-shaped object for role-only is_verb_allowed checks.""" - - status: str = "in_progress" - task_type: str = "code" - - -_ROLE_PROBE = _RoleProbeTask() +_VALID_NOTIFY_PRIORITIES: frozenset[str] = frozenset(p.value for p in _comms.Priority) class ContentActions: @@ -126,7 +127,7 @@ class ContentActions: """ agent = await self.task.agent_for(agent_id) caller_role = str(agent.role) if agent is not None else "" - if not is_verb_allowed(caller_role, "commit", _ROLE_PROBE): + if caller_role not in _COMMIT_ALLOWED_ROLES: return Envelope.not_authorized( message=( f"role '{caller_role}' may not commit code; only" @@ -250,6 +251,20 @@ class ContentActions: get_agent_channels, ) + # Spec §5.5: auditor is silent — defense-in-depth runtime guard. + # The spawn manifest already omits `say` from the auditor's tool + # surface, but that is convention-only. This guard refuses any + # call that bypassed the manifest (direct verb dispatch, test + # harness, future routing change) so the silent-observer rule + # holds regardless of how the call arrived. + agent = await self.task.agent_for(agent_id) + if agent is not None and str(agent.role) == "auditor": + return Envelope.not_authorized( + message="auditor is a silent observer; say is not permitted", + remediate="record observations via note(scope='reflect') instead", + context_briefing={}, + ) + if task_id is not None: if reject := await self._verify_explicit_task_ownership(agent_id, task_id): return reject @@ -291,6 +306,17 @@ class ContentActions: skill: str | None = None, ) -> Envelope: """A2A direct message. Requires task_id (active or explicit).""" + # Spec §5.5: auditor is silent — defense-in-depth runtime guard. + # See say() above for rationale. Mirrored here because dm() is + # the other channel through which the auditor could "speak". + agent = await self.task.agent_for(agent_id) + if agent is not None and str(agent.role) == "auditor": + return Envelope.not_authorized( + message="auditor is a silent observer; dm is not permitted", + remediate="record observations via note(scope='reflect') instead", + context_briefing={}, + ) + if task_id is not None: if reject := await self._verify_explicit_task_ownership(agent_id, task_id): return reject @@ -354,7 +380,7 @@ class ContentActions: ) agent = await self.task.agent_for(agent_id) caller_role = str(agent.role) if agent is not None else "" - if not is_verb_allowed(caller_role, "notify", _ROLE_PROBE): + if caller_role not in _NOTIFY_ALLOWED_ROLES: return Envelope.not_authorized( message=( f"role {caller_role!r} cannot send formal notifications; " diff --git a/roboco/services/gateway/envelope.py b/roboco/services/gateway/envelope.py index 70f92047..18399592 100644 --- a/roboco/services/gateway/envelope.py +++ b/roboco/services/gateway/envelope.py @@ -33,6 +33,9 @@ class Envelope: message: str | None = None remediate: str | None = None missing: list[str] | None = None + # Populated only by `incomplete_input` envelopes — the literal + # answer-key the agent uses to re-issue the call (spec §5.2.1). + field_hints: dict[str, str] | None = None # Stamped post-construction by the route layer from # ``request.state.correlation_id`` (set by ``CorrelationIdMiddleware``). # Carried back to the agent so the same id flows MCP -> API -> agent @@ -79,6 +82,29 @@ class Envelope: context_briefing=context_briefing or {}, ) + @classmethod + def incomplete_input( + cls, + *, + missing: list[str], + field_hints: dict[str, str], + remediate: str, + context_briefing: dict[str, Any] | None = None, + ) -> Envelope: + """Structured rejection for under-filled inputs (spec §5.2.1). + + Distinct from `tracing_gap`. The agent receives a literal + answer-key (`field_hints`) and re-issues the call with each + missing field filled. + """ + return cls( + error="incomplete_input", + missing=missing, + field_hints=field_hints, + remediate=remediate, + context_briefing=context_briefing or {}, + ) + @classmethod def invalid_state( cls, @@ -113,16 +139,95 @@ class Envelope: def not_found(cls, *, message: str) -> Envelope: return cls(error="not_found", message=message, context_briefing={}) + @classmethod + def circuit_open( + cls, + *, + verb: str, + attempts: int, + window_seconds: int, + remediate: str, + context_briefing: dict[str, Any] | None = None, + ) -> Envelope: + """Per-verb retry circuit-breaker tripped — too many attempts in a window. + + Distinct from `tracing_gap` and `incomplete_input`. The agent receives + a structured "stop hammering this verb" signal with a remediate hint + pointing to i_am_blocked() / i_am_idle() as graceful exits. Wired by + the agent_sdk runtime tracker (Phase 3 Task 14) — the gateway itself + does not raise this. + """ + return cls( + error="circuit_open", + message=( + f"verb {verb!r} rejected {attempts} times in last " + f"{window_seconds}s — circuit breaker open" + ), + remediate=remediate, + context_briefing=context_briefing or {}, + ) + + @classmethod + def from_decision( + cls, decision: Any, *, briefing: dict[str, Any] | None = None + ) -> Envelope: + """Map a lifecycle.spec.Decision rejection onto the right envelope flavor. + + Allow Decisions are a programming error here — call sites must + check `decision.allowed` before invoking this. + """ + if decision.allowed: + raise ValueError("cannot build rejection from allow Decision") + ctx = briefing or {} + kind = decision.rejection_kind + if kind == "tracing_gap": + return cls( + error="tracing_gap", + missing=list(decision.missing), + remediate=decision.remediate or "", + context_briefing=ctx, + ) + if kind == "self_review": + return cls( + error="not_authorized", + message=(decision.message or "") + " (self-review blocked)", + remediate=decision.remediate, + context_briefing=ctx, + ) + if kind == "not_found": + return cls( + error="not_found", + message=decision.message, + context_briefing=ctx, + ) + # not_authorized | invalid_state — direct map + return cls( + error=kind, + message=decision.message, + remediate=decision.remediate, + context_briefing=ctx, + ) + def with_introspection(self, *, task: Any, role: str) -> Envelope: """Populate `current_state` and `valid_next_verbs` from a task + role. - Returns self for chaining. Imports verb_gates lazily so envelope.py - stays importable from any layer without dragging in the gates table. + Returns self for chaining. Imports the lifecycle spec lazily so + envelope.py stays importable from any layer without dragging in + the canonical spec module. Unknown roles or malformed task + statuses yield `[]` — preserves the legacy verb_gates contract + of "introspection is best-effort and never raises". """ - from roboco.services.gateway.verb_gates import valid_next_verbs + from roboco.foundation.policy import lifecycle as spec self.current_state = str(getattr(task, "status", "") or "") or None - self.valid_next_verbs = valid_next_verbs(role, task) + try: + role_enum = spec.Role(role) + self.valid_next_verbs = spec.valid_next_verbs(role_enum, task) + except (ValueError, TypeError): + # Unknown role string OR task.status not a Status enum value + # (e.g. AsyncMock in tests, partial fixtures). Match legacy + # verb_gates semantics: best-effort, never raise. + self.valid_next_verbs = [] return self def as_dict(self) -> dict[str, Any]: @@ -143,4 +248,6 @@ class Envelope: out["remediate"] = self.remediate if self.missing is not None: out["missing"] = self.missing + if self.field_hints is not None: + out["field_hints"] = self.field_hints return out diff --git a/roboco/services/gateway/remediation.py b/roboco/services/gateway/remediation.py index 7f57a6ee..9aa833d6 100644 --- a/roboco/services/gateway/remediation.py +++ b/roboco/services/gateway/remediation.py @@ -71,3 +71,18 @@ def hint_for_missing_qa_notes() -> str: def hint_for_evidence_not_inspected(*, task_id: str) -> str: return f"call evidence(task_id='{task_id}') to inspect the PR before pass/fail" + + +def hint_for_short_doc_notes(*, min_chars: int) -> str: + return ( + f"i_documented requires notes>=" + f"{min_chars} chars summarizing what you " + "documented and where (file paths); pass a longer `notes` argument" + ) + + +def hint_for_missing_doc_files() -> str: + return ( + "i_documented(files=[...]) requires the list of doc-file paths you " + "committed; pass at least one path" + ) diff --git a/roboco/services/gateway/role_config.py b/roboco/services/gateway/role_config.py index b3a33fdd..37bcbaaa 100644 --- a/roboco/services/gateway/role_config.py +++ b/roboco/services/gateway/role_config.py @@ -3,12 +3,21 @@ Source of truth for which verbs and content tools each role gets at spawn time. The spawn manifest builder reads from here. The MCP servers (Phase 1+) also reference this catalog to scope their tool registration per role. + +Flow-tool tuples (`_DEV_FLOW`, `_QA_FLOW`, ...) are derived from +`roboco.foundation.policy.lifecycle.intents_for_role`. The spec is canon +— adding or removing a role from an `IntentSpec.allowed_roles` +automatically updates the MCP manifest. This module is a thin shim that +adds the do-tool / write / subagent / description metadata the spec does +not carry. """ from __future__ import annotations from dataclasses import dataclass +from roboco.foundation.policy import lifecycle as spec + @dataclass(frozen=True) class RoleConfig: @@ -22,80 +31,26 @@ class RoleConfig: description: str -_DEV_FLOW = ( - "give_me_work", - "i_will_work_on", - "open_pr", - "i_am_done", - "i_am_blocked", - "unclaim", - "resume", - "i_am_idle", -) +_DEV_FLOW = spec.intents_for_role(spec.Role.DEVELOPER) _DEV_DO = ("commit", "note", "say", "dm", "evidence") -_QA_FLOW = ( - "give_me_work", - "claim_review", - "pass", - "fail", - "unclaim", - "resume", - "i_am_idle", -) +_QA_FLOW = spec.intents_for_role(spec.Role.QA) _QA_DO = ("note", "say", "dm", "evidence") -_DOC_FLOW = ( - "give_me_work", - "claim_doc_task", - "i_documented", - "unclaim", - "resume", - "i_am_idle", -) +_DOC_FLOW = spec.intents_for_role(spec.Role.DOCUMENTER) _DOC_DO = ("commit", "note", "say", "dm", "evidence") -_CELL_PM_FLOW = ( - "give_me_work", - "i_will_plan", - "delegate", - "submit_up", - "triage", - "unblock", - "complete", - "escalate_up", - "unclaim", - "resume", - "i_am_idle", -) +_CELL_PM_FLOW = spec.intents_for_role(spec.Role.CELL_PM) _CELL_PM_DO = ("note", "say", "dm", "notify", "evidence") -_MAIN_PM_FLOW = ( - "give_me_work", - "i_will_plan", - "delegate", - "triage_all", - "unblock", - "complete", - "escalate_up", - "escalate_to_ceo", - "unclaim", - "resume", - "i_am_idle", -) +_MAIN_PM_FLOW = spec.intents_for_role(spec.Role.MAIN_PM) _MAIN_PM_DO = ("note", "say", "dm", "notify", "evidence") -_BOARD_FLOW = ( - "triage", - "escalate_to_ceo", - "i_am_idle", -) +_PRODUCT_OWNER_FLOW = spec.intents_for_role(spec.Role.PRODUCT_OWNER) +_HEAD_MARKETING_FLOW = spec.intents_for_role(spec.Role.HEAD_MARKETING) _BOARD_DO = ("note", "say", "dm", "notify", "evidence") -_AUDITOR_FLOW = ( - "triage", - "i_am_idle", -) +_AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR) _AUDITOR_DO = ("note", "evidence") # auditor reads, does not chat or escalate @@ -142,7 +97,7 @@ ROLE_CONFIGS: dict[str, RoleConfig] = { ), "product_owner": RoleConfig( role="product_owner", - flow_tools=_BOARD_FLOW, + flow_tools=_PRODUCT_OWNER_FLOW, do_tools=_BOARD_DO, allows_write=False, allows_subagent=True, @@ -150,7 +105,7 @@ ROLE_CONFIGS: dict[str, RoleConfig] = { ), "head_marketing": RoleConfig( role="head_marketing", - flow_tools=_BOARD_FLOW, + flow_tools=_HEAD_MARKETING_FLOW, do_tools=_BOARD_DO, allows_write=False, allows_subagent=True, diff --git a/roboco/services/gateway/tracing_gate.py b/roboco/services/gateway/tracing_gate.py deleted file mode 100644 index 8c3489f4..00000000 --- a/roboco/services/gateway/tracing_gate.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Precondition checks for tracing completeness. - -Pure functions over a Task model + a GateContext (ambient flags + thresholds). -The choreographer queries journal/qa state, builds a GateContext, and calls -check_requirements; this module decides pass/fail and returns the missing -requirements with concrete error keys. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field -from enum import StrEnum -from typing import Any - - -class Requirement(StrEnum): - PLAN = "plan" - PROGRESS_AT_LEAST_ONE = "progress>=1" - JOURNAL_REFLECT = "journal:reflect" - JOURNAL_DECISION = "journal:decision" - JOURNAL_LEARNING = "journal:learning" - ACCEPTANCE_CRITERIA_ADDRESSED = "acceptance_criteria_addressed" - QA_NOTES_MIN_CHARS = "qa_notes>=min" - QA_EVIDENCE_INSPECTED = "qa_evidence_inspected" - SELF_VERIFIED = "self_verified" - - -@dataclass(frozen=True) -class GateContext: - """Ambient inputs the checker needs that don't live on the Task model.""" - - journal_reflect_present: bool = False - journal_decision_present: bool = False - journal_learning_present: bool = False - qa_notes_min_chars: int = 80 - - -@dataclass(frozen=True) -class GateResult: - passed: bool - missing: list[str] = field(default_factory=list) - - -# A Checker takes (task, ctx) and returns the missing key(s) — empty list when -# the requirement is satisfied. -Checker = Callable[[Any, GateContext], list[str]] - - -def _check_plan(task: Any, _ctx: GateContext) -> list[str]: - return [] if task.plan else ["plan"] - - -def _check_progress(task: Any, _ctx: GateContext) -> list[str]: - has_progress = bool(task.progress_updates) and len(task.progress_updates) >= 1 - return [] if has_progress else ["progress>=1"] - - -def _check_journal_reflect(_task: Any, ctx: GateContext) -> list[str]: - return [] if ctx.journal_reflect_present else ["journal:reflect"] - - -def _check_journal_decision(_task: Any, ctx: GateContext) -> list[str]: - return [] if ctx.journal_decision_present else ["journal:decision"] - - -def _check_journal_learning(_task: Any, ctx: GateContext) -> list[str]: - return [] if ctx.journal_learning_present else ["journal:learning"] - - -def _check_acceptance_criteria(task: Any, _ctx: GateContext) -> list[str]: - return [f"acceptance_criterion:{c}" for c in _unaddressed_criteria(task)] - - -def _check_qa_notes_min_chars(task: Any, ctx: GateContext) -> list[str]: - notes = task.qa_notes or "" - return [] if len(notes) >= ctx.qa_notes_min_chars else ["qa_notes>=min"] - - -def _check_qa_evidence_inspected(task: Any, _ctx: GateContext) -> list[str]: - return [] if task.qa_evidence_inspected else ["qa_evidence_inspected"] - - -def _check_self_verified(task: Any, _ctx: GateContext) -> list[str]: - return [] if task.self_verified else ["self_verified"] - - -_CHECKERS: dict[Requirement, Checker] = { - Requirement.PLAN: _check_plan, - Requirement.PROGRESS_AT_LEAST_ONE: _check_progress, - Requirement.JOURNAL_REFLECT: _check_journal_reflect, - Requirement.JOURNAL_DECISION: _check_journal_decision, - Requirement.JOURNAL_LEARNING: _check_journal_learning, - Requirement.ACCEPTANCE_CRITERIA_ADDRESSED: _check_acceptance_criteria, - Requirement.QA_NOTES_MIN_CHARS: _check_qa_notes_min_chars, - Requirement.QA_EVIDENCE_INSPECTED: _check_qa_evidence_inspected, - Requirement.SELF_VERIFIED: _check_self_verified, -} - - -def check_requirements( - task: Any, - requirements: list[Requirement], - ctx: GateContext | None = None, -) -> GateResult: - """Check that every requirement is met. Returns pass + list of missing keys.""" - context = ctx or GateContext() - missing: list[str] = [] - for req in requirements: - missing.extend(_CHECKERS[req](task, context)) - return GateResult(passed=len(missing) == 0, missing=missing) - - -def _unaddressed_criteria(task: Any) -> list[str]: - """Return acceptance criteria text values that have no referencing artifact.""" - criteria: list[str] = list(task.acceptance_criteria or []) - status: list[dict] = list(task.acceptance_criteria_status or []) - addressed = { - s["criterion"] - for s in status - if isinstance(s, dict) and s.get("referencing_artifact_id") - } - return [c for c in criteria if c not in addressed] diff --git a/roboco/services/gateway/verb_gates.py b/roboco/services/gateway/verb_gates.py deleted file mode 100644 index b5af97a5..00000000 --- a/roboco/services/gateway/verb_gates.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Single declarative table mapping (role, task_status) -> valid verbs. - -Used by: - 1. Envelope introspection - every envelope carries `valid_next_verbs` - so agents know what's callable without trial-and-error. - 2. Role-check guards - instead of scattered `if role in PM_ROLES` - checks, verbs ask `is_verb_allowed(role, verb, task)`. - -Pre-2026-05-08, this logic lived in three places (role_config.py, -claim_guards.py, choreographer string constants) and could disagree -silently. This module is the single source of truth. -""" - -from __future__ import annotations - -from typing import Any - -# Roles that PLAN and DELEGATE; never EXECUTE code. -_PM_ROLES: frozenset[str] = frozenset({"cell_pm", "main_pm"}) - -# Always-available verbs (don't depend on task state). -# -# `notify` (formal acked notifications) is on the PM/Board roles only — -# pre-gateway it was gated by `_NOTIFY_ALLOWED_ROLES` in -# `content_actions.py`; now consolidated here. -_ALWAYS_AVAILABLE: dict[str, frozenset[str]] = { - "developer": frozenset({"i_am_idle", "give_me_work"}), - "qa": frozenset({"i_am_idle", "give_me_work"}), - "documenter": frozenset({"i_am_idle", "give_me_work"}), - "cell_pm": frozenset({"i_am_idle", "give_me_work", "triage", "notify"}), - "main_pm": frozenset({"i_am_idle", "give_me_work", "triage_all", "notify"}), - "product_owner": frozenset({"i_am_idle", "triage", "notify"}), - "head_marketing": frozenset({"i_am_idle", "triage", "notify"}), - "auditor": frozenset({"i_am_idle", "triage"}), -} - -# (role, status) -> tuple of additional verbs valid in that state. -# Verbs in _ALWAYS_AVAILABLE are added on top. -_STATE_VERBS: dict[tuple[str, str], tuple[str, ...]] = { - # Developer - ("developer", "pending"): ("i_will_work_on", "unclaim"), - ("developer", "needs_revision"): ("i_will_work_on", "unclaim"), - ("developer", "claimed"): ( - "commit", - "open_pr", - "i_am_done", - "i_am_blocked", - "unclaim", - ), - ("developer", "in_progress"): ( - "commit", - "open_pr", - "i_am_done", - "i_am_blocked", - "unclaim", - ), - ("developer", "verifying"): ( - "commit", - "open_pr", - "i_am_done", - "i_am_blocked", - ), - ("developer", "blocked"): ("resume", "i_am_blocked", "unclaim"), - ("developer", "paused"): ("resume",), - # QA - ("qa", "awaiting_qa"): ("claim_review",), - ("qa", "claimed"): ("pass", "fail", "unclaim"), - ("qa", "in_progress"): ("pass", "fail", "unclaim"), - # Documenter - ("documenter", "awaiting_documentation"): ("claim_doc_task",), - ("documenter", "claimed"): ("commit", "i_documented", "unclaim"), - ("documenter", "in_progress"): ("commit", "i_documented", "unclaim"), - # Cell PM - PMs PLAN code-typed parents; they don't EXECUTE. - ("cell_pm", "pending"): ("i_will_plan", "unclaim"), - ("cell_pm", "claimed"): ("delegate", "unblock", "complete", "escalate_up"), - ("cell_pm", "in_progress"): ("delegate", "unblock", "complete", "escalate_up"), - ("cell_pm", "blocked"): ("unblock", "resume"), - ("cell_pm", "awaiting_pm_review"): ("complete", "submit_up", "escalate_up"), - # Main PM - ("main_pm", "pending"): ("i_will_plan", "unclaim"), - ("main_pm", "claimed"): ( - "delegate", - "unblock", - "complete", - "escalate_to_ceo", - ), - ("main_pm", "in_progress"): ( - "delegate", - "unblock", - "complete", - "escalate_to_ceo", - ), - ("main_pm", "blocked"): ("unblock", "resume"), - ("main_pm", "awaiting_pm_review"): ("complete", "escalate_to_ceo"), -} - - -def valid_next_verbs(role: str, task: Any) -> list[str]: - """Return the verbs `role` can usefully call on `task` right now. - - `task` must expose `.status` (str) and `.task_type` (str). For a - PM-role caller against a code-typed task in pending status the - result includes `i_will_plan` (PMs plan any task type), but never - `i_will_work_on` (which is the developer execution verb). - - Returns [] for an unknown role. Returns the always-available - subset for an unknown status. - """ - always = _ALWAYS_AVAILABLE.get(role) - if always is None: - return [] - status = str(getattr(task, "status", "")) - state_verbs = _STATE_VERBS.get((role, status), ()) - return sorted(set(always) | set(state_verbs)) - - -def is_verb_allowed(role: str, verb: str, task: Any) -> bool: - """Quick check: can `role` call `verb` on `task` right now?""" - return verb in valid_next_verbs(role, task) diff --git a/roboco/services/journal.py b/roboco/services/journal.py index 371fbe90..5088a3d2 100644 --- a/roboco/services/journal.py +++ b/roboco/services/journal.py @@ -14,6 +14,9 @@ from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from roboco.db.tables import JournalEntryTable, JournalTable +from roboco.foundation.policy.journaling import ( + SCOPE_TO_TYPE as _FOUNDATION_SCOPE_TO_TYPE, +) from roboco.models.base import JournalEntryType from roboco.models.journal import ( DecisionLogParams, @@ -37,6 +40,13 @@ from roboco.models.optimal import IndexJournalEntryParams from roboco.services.base import BaseService from roboco.utils.converters import require_uuid, to_python_uuid +# Scope mapping is canonical in foundation.policy.journaling. +# Derived as string-keyed dict here because the service's call sites pass +# scope strings (from the gateway's content_actions layer) not Scope enums. +_SCOPE_TO_TYPE: dict[str, JournalEntryType] = { + scope.value: entry_type for scope, entry_type in _FOUNDATION_SCOPE_TO_TYPE.items() +} + class JournalService(BaseService): """ @@ -760,6 +770,17 @@ class JournalService(BaseService): agent_id, task_id, JournalEntryType.DECISION_LOG ) + async def has_note_for_task(self, agent_id: UUID, task_id: UUID) -> bool: + """True iff a GENERAL (scope='note') entry exists for (agent, task). + + Backs the JOURNAL_NOTE_AT_CLAIM tracing requirement on + i_will_work_on (pre-gateway parity P1: developers wrote a + work_log/note entry on every claim). + """ + return await self._has_entry_of_type( + agent_id, task_id, JournalEntryType.GENERAL + ) + async def has_learning_for_task(self, agent_id: UUID, task_id: UUID) -> bool: """True iff a LEARNING entry exists for (agent, task).""" return await self._has_entry_of_type( @@ -772,6 +793,12 @@ class JournalService(BaseService): agent_id, task_id, JournalEntryType.TASK_REFLECTION ) + async def has_struggle_for_task(self, agent_id: UUID, task_id: UUID) -> bool: + """True iff a STRUGGLE entry exists for (agent, task).""" + return await self._has_entry_of_type( + agent_id, task_id, JournalEntryType.STRUGGLE + ) + async def write_struggle( self, *, @@ -794,17 +821,6 @@ class JournalService(BaseService): ) return await self.add_struggle(agent_id, params) - # Mapping from gateway scope strings (note/decision/reflect/learning/struggle) - # to canonical JournalEntryType enum values. Defined as a class-level constant - # so the lookup is a single dict access per call. - _SCOPE_TO_TYPE: ClassVar[dict[str, JournalEntryType]] = { - "note": JournalEntryType.GENERAL, - "decision": JournalEntryType.DECISION_LOG, - "reflect": JournalEntryType.TASK_REFLECTION, - "learning": JournalEntryType.LEARNING, - "struggle": JournalEntryType.STRUGGLE, - } - async def write_entry( self, *, @@ -828,11 +844,10 @@ class JournalService(BaseService): scopes. Caller (gateway) validates the scope set before reaching here, but the guard is kept defensive. """ - entry_type = self._SCOPE_TO_TYPE.get(scope) + entry_type = _SCOPE_TO_TYPE.get(scope) if entry_type is None: raise ValueError( - f"unknown scope {scope!r}; " - f"expected one of {sorted(self._SCOPE_TO_TYPE)}" + f"unknown scope {scope!r}; expected one of {sorted(_SCOPE_TO_TYPE)}" ) journal = await self.get_or_create_journal(agent_id) return await self.create_entry( diff --git a/roboco/services/notification.py b/roboco/services/notification.py index a0bce93b..5814e7e4 100644 --- a/roboco/services/notification.py +++ b/roboco/services/notification.py @@ -249,13 +249,29 @@ class NotificationService: Args: task_id: Related task ID - a2a_context: Dict with from_agent, to_agent, skill, message, urgent + a2a_context: Dict with from_agent, to_agent, skill, message, + priority. `priority` is a `NotificationPriority` (full + tristate: NORMAL / HIGH / URGENT). Before P3 Task 9 this + key was `urgent: bool` which collapsed HIGH to NORMAL — + A2AService now sends Priority directly. """ from_agent = a2a_context.get("from_agent", "unknown") to_agent = a2a_context.get("to_agent", "") skill = a2a_context.get("skill", "general") message = a2a_context.get("message", "") - urgent = a2a_context.get("urgent", False) + priority = a2a_context.get("priority", NotificationPriority.NORMAL) + # Defensive coerce — accept enum, str, or a stray bool from a + # legacy caller. The point of Task 9 is that HIGH survives, so + # only collapse to URGENT/NORMAL if the input is genuinely a bool. + if isinstance(priority, bool): + priority = ( + NotificationPriority.URGENT if priority else NotificationPriority.NORMAL + ) + elif not isinstance(priority, NotificationPriority): + try: + priority = NotificationPriority(str(priority)) + except ValueError: + priority = NotificationPriority.NORMAL logger.info( "Sending A2A notification", @@ -263,18 +279,19 @@ class NotificationService: from_agent=from_agent, to_agent=to_agent, skill=skill, - urgent=urgent, + priority=priority.value, ) - urgency_label = "[URGENT] " if urgent else "" + # Cosmetic [URGENT] prefix stays urgent-only. HIGH is recorded at + # the NotificationTable.priority column but gets no body/subject + # prefix — the column is the source of truth for routing, the + # label is just an attention hint for the human-readable body. + urgency_label = "[URGENT] " if priority == NotificationPriority.URGENT else "" body = ( f"{urgency_label}A2A request from {from_agent}.\n\n" f"Skill: {skill}\n\n" f"Message: {message}" ) - priority = ( - NotificationPriority.URGENT if urgent else NotificationPriority.NORMAL - ) await self._create_notification( CreateNotificationParams( notification_type=NotificationType.A2A_REQUEST, diff --git a/roboco/services/notification_delivery.py b/roboco/services/notification_delivery.py index 16a41489..8ced7138 100644 --- a/roboco/services/notification_delivery.py +++ b/roboco/services/notification_delivery.py @@ -24,6 +24,7 @@ from roboco.agents_config import ( ) from roboco.db.tables import AgentTable, NotificationTable, TaskTable from roboco.events import Event, EventType, get_event_bus +from roboco.foundation.policy.communications import ACK_REQUIRED_BY_TYPE from roboco.models.base import AgentRole, NotificationPriority, NotificationType from roboco.services.base import BaseService, NotFoundError from roboco.utils.converters import require_uuid @@ -496,8 +497,8 @@ class NotificationDeliveryService(BaseService): task_title = task.title or "Untitled" notification = NotificationTable( - type="blocker_escalation", - priority="high", + type=NotificationType.BLOCKER_ESCALATION, + priority=NotificationPriority.HIGH, from_agent=blocker_agent_id, to_agents=[pm.id], subject=f"ACTION REQUIRED: Blocked - {task_title[:40]}", @@ -513,7 +514,7 @@ class NotificationDeliveryService(BaseService): "the task will remain blocked until you call the tool." ), related_task_id=task_id, - requires_ack=True, + requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.BLOCKER_ESCALATION], read_by=[], acked_by=[], ) @@ -533,8 +534,8 @@ class NotificationDeliveryService(BaseService): task.assigned_to = pm.id notification = NotificationTable( - type="task_assignment", - priority="normal", + type=NotificationType.TASK_ASSIGNMENT, + priority=NotificationPriority.NORMAL, from_agent=submitter_agent_id, to_agents=[pm.id], subject=f"Documentation complete: {task.title or 'Unknown task'}", @@ -543,7 +544,7 @@ class NotificationDeliveryService(BaseService): "for final review.\n\nPlease review and complete the task." ), related_task_id=task_id, - requires_ack=False, + requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.TASK_ASSIGNMENT], ) await self._persist_and_deliver(notification) @@ -562,8 +563,8 @@ class NotificationDeliveryService(BaseService): task.assigned_to = pm.id notification = NotificationTable( - type="task_assignment", - priority="normal", + type=NotificationType.TASK_ASSIGNMENT, + priority=NotificationPriority.NORMAL, from_agent=submitter_agent_id, to_agents=[pm.id], subject=f"Task ready for review: {task.title or 'Unknown task'}", @@ -573,7 +574,7 @@ class NotificationDeliveryService(BaseService): "Please review and complete the task." ), related_task_id=task_id, - requires_ack=False, + requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.TASK_ASSIGNMENT], ) await self._persist_and_deliver(notification) @@ -587,8 +588,8 @@ class NotificationDeliveryService(BaseService): ) -> None: """Notify the task's assigned agent that their task is unblocked.""" notification = NotificationTable( - type="task_assignment", - priority="high", + type=NotificationType.TASK_ASSIGNMENT, + priority=NotificationPriority.HIGH, from_agent=from_agent_id, to_agents=[assignee_agent_id], subject=f"Task unblocked: {task.title or 'Unknown task'}", @@ -597,7 +598,7 @@ class NotificationDeliveryService(BaseService): "Use roboco_task_get to review the task and continue work." ), related_task_id=task_id, - requires_ack=False, + requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.TASK_ASSIGNMENT], ) await self._persist_and_deliver(notification) @@ -612,8 +613,8 @@ class NotificationDeliveryService(BaseService): ) -> None: """Notify the task's assignee that CEO rejected and sent back for revision.""" notification = NotificationTable( - type="task_assignment", - priority="high", + type=NotificationType.APPROVAL, + priority=NotificationPriority.HIGH, from_agent=from_agent_id, to_agents=[assignee_agent_id], subject=f"CEO Revision Required: {task.title or 'Unknown task'}", @@ -623,7 +624,7 @@ class NotificationDeliveryService(BaseService): "Please address the feedback and resubmit." ), related_task_id=task_id, - requires_ack=True, + requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL], ) await self._persist_and_deliver(notification) @@ -663,14 +664,14 @@ class NotificationDeliveryService(BaseService): body = f"Task {task_id} escalated by {escalator.slug}.\n\nReason: {reason}" notification = NotificationTable( - type="blocker_escalation", - priority="high", + type=NotificationType.BLOCKER_ESCALATION, + priority=NotificationPriority.HIGH, from_agent=escalator_agent_id, to_agents=[target.id], subject=f"Escalation: {task.title or 'Unknown task'}", body=body, related_task_id=task_id, - requires_ack=True, + requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.BLOCKER_ESCALATION], read_by=[], acked_by=[], ) @@ -696,8 +697,8 @@ class NotificationDeliveryService(BaseService): return notification = NotificationTable( - type="task_assignment", - priority="high", + type=NotificationType.APPROVAL, + priority=NotificationPriority.HIGH, from_agent=escalator_agent_id, to_agents=[ceo.id], subject=f"CEO Approval Required: {task.title or 'Unknown task'}", @@ -708,7 +709,7 @@ class NotificationDeliveryService(BaseService): "Use /ceo-approve or /ceo-reject to respond." ), related_task_id=task_id, - requires_ack=True, + requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL], ) await self._persist_and_deliver(notification) diff --git a/roboco/services/permissions.py b/roboco/services/permissions.py index 849b8915..b57af67e 100644 --- a/roboco/services/permissions.py +++ b/roboco/services/permissions.py @@ -33,11 +33,12 @@ from roboco.agents_config import ( AGENT_ROLE_MAP, AGENT_TEAM_MAP, CHANNEL_ACCESS, - NOTIFICATION_PERMISSIONS, ) from roboco.agents_config import ( get_agent_role as get_role_string, ) +from roboco.foundation.identity import Role as _FoundationRole +from roboco.foundation.policy.communications import NOTIFY_SENDER_ROLES from roboco.models import AgentRole, Team from roboco.models.permissions import ( COMMUNICATION_MATRIX, @@ -72,21 +73,57 @@ def _get_agents_for_role_team(role: AgentRole, team: Team | None) -> list[str]: # ============================================================================= -# NOTIFICATION PERMISSIONS (derived from agents_config.NOTIFICATION_PERMISSIONS) +# NOTIFICATION PERMISSIONS (derived from foundation.NOTIFY_SENDER_ROLES) # ============================================================================= +# +# Foundation owns the sender allowlist. Scope semantics (who each sender may +# reach) live here because they depend on the recipient's role + team — not +# pure identity data. The mapping below preserves the legacy +# NOTIFICATION_PERMISSIONS behaviour: +# - main_pm / ceo -> "all" (no recipient filter) +# - cell_pm -> "cell" (own team members + any PM) +# - product_owner -> list (management chain only) +# - head_marketing -> list (management chain only) +# Auditor is intentionally NOT a sender (silent observer per spec §5.5). + +# Board members notify the management chain only. Roles, not slugs — matched +# against recipient.role in can_notify(). Each list is the set of recipient +# roles the sender may notify. +_BOARD_NOTIFY_TARGETS: dict[AgentRole, frozenset[AgentRole]] = { + AgentRole.PRODUCT_OWNER: frozenset( + {AgentRole.MAIN_PM, AgentRole.HEAD_MARKETING, AgentRole.AUDITOR, AgentRole.CEO} + ), + AgentRole.HEAD_MARKETING: frozenset( + {AgentRole.MAIN_PM, AgentRole.PRODUCT_OWNER, AgentRole.AUDITOR, AgentRole.CEO} + ), +} def _can_role_send_notifications(role: AgentRole) -> bool: - """Check if a role can send notifications (from agents_config).""" - perms = NOTIFICATION_PERMISSIONS.get(role.value, {}) - return bool(perms.get("can_send", False)) + """Whether a role may call notify(). Canonical in foundation.""" + try: + return _FoundationRole(role.value) in NOTIFY_SENDER_ROLES + except ValueError: + return False -def _get_notification_scope(role: AgentRole) -> str | list[str]: - """Get the notification scope for a role (from agents_config).""" - perms = NOTIFICATION_PERMISSIONS.get(role.value, {}) - scope = perms.get("scope", []) - return str(scope) if isinstance(scope, str) else list(scope) if scope else [] +def _get_notification_scope(role: AgentRole) -> str | list[AgentRole]: + """Scope of recipients a sender role may notify. + + Returns: + - ``"all"`` for main_pm / ceo (no recipient filter) + - ``"cell"`` for cell_pm (own team + any PM) + - ``list[AgentRole]`` for board members (management chain only) + - ``[]`` for roles that cannot send notifications + """ + if role in (AgentRole.MAIN_PM, AgentRole.CEO): + return "all" + if role is AgentRole.CELL_PM: + return "cell" + targets = _BOARD_NOTIFY_TARGETS.get(role) + if targets is not None: + return list(targets) + return [] # ============================================================================= @@ -215,7 +252,7 @@ class PermissionService(SingletonService): return channels # ========================================================================= - # NOTIFICATION PERMISSIONS (uses agents_config.NOTIFICATION_PERMISSIONS) + # NOTIFICATION PERMISSIONS (foundation.NOTIFY_SENDER_ROLES + local scope) # ========================================================================= def can_send_notifications(self, agent: AgentContext) -> bool: @@ -227,10 +264,10 @@ class PermissionService(SingletonService): sender: AgentContext, recipient: AgentContext, ) -> bool: - """ - Check if sender can notify recipient. + """Check if sender can notify recipient. - Uses agents_config.NOTIFICATION_PERMISSIONS for scope rules. + Sender allowlist comes from foundation.NOTIFY_SENDER_ROLES. + Scope rules are encoded in _get_notification_scope. """ if not self.can_send_notifications(sender): return False @@ -249,11 +286,9 @@ class PermissionService(SingletonService): # Otherwise must be same team return sender.team == recipient.team - # List scope - check if recipient slug is in the allowed list + # List scope - check if recipient.role is in the allowed role list if isinstance(scope, list): - # Get recipient's potential slugs - recipient_slugs = _get_agents_for_role_team(recipient.role, recipient.team) - return any(slug in scope for slug in recipient_slugs) + return recipient.role in scope return False @@ -395,14 +430,15 @@ class PermissionService(SingletonService): return agent_slug in write_list def can_agent_send_notifications(self, agent_slug: str) -> bool: - """ - Check notification permission using agent slug (string ID). + """Check notification permission using agent slug (string ID). - Direct lookup in agents_config.NOTIFICATION_PERMISSIONS. + Derives from foundation.NOTIFY_SENDER_ROLES via the agent's role. """ role = get_role_string(agent_slug) - perms = NOTIFICATION_PERMISSIONS.get(role, {}) - return bool(perms.get("can_send", False)) + try: + return _FoundationRole(role) in NOTIFY_SENDER_ROLES + except ValueError: + return False # ============================================================================= @@ -432,8 +468,10 @@ async def has_privileged_access(db: "AsyncSession", agent_id: UUID) -> bool: return role in PRIVILEGED_ROLES if role else False -# Roles that can manage tasks and sessions (PMs and board) -PM_ROLES = frozenset({AgentRole.CELL_PM, AgentRole.MAIN_PM}) +# PM_ROLES is canonical in foundation.identity. Re-export for backwards +# compatibility; new consumers import from foundation directly. +from roboco.foundation.identity import PM_ROLES # noqa: F401, E402 + MANAGEMENT_ROLES = frozenset( {AgentRole.CEO, AgentRole.PRODUCT_OWNER, AgentRole.CELL_PM, AgentRole.MAIN_PM} ) diff --git a/roboco/services/task.py b/roboco/services/task.py index e36f0555..2d13a27d 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -5051,15 +5051,41 @@ class TaskService(BaseService): assignee it stays in ``backlog`` and a PM must run ``activate`` later. Caller-supplied status takes precedence; otherwise we infer from the presence of an assignee. + + Foundation rule: no task without acceptance_criteria. The silent + fallback that substituted ``["completed and reviewed by assignee"]`` + was deleted on 2026-05-10 (spec §5.2) — it was the proximate cause + of every skeleton task in the same-day smoke run. Defense-in-depth: + the gateway and route-layer schemas reject under-filled tasks + earlier, but this service-layer guard remains as a hard backstop + for non-gateway / non-route callers. """ + from roboco.foundation.policy.task_completeness import ( + TASK_AT_CREATE, + TaskCompletenessError, + check, + ) + if req.parent_task_id is None: raise ValueError("create_subtask requires parent_task_id") + + result = check(TASK_AT_CREATE, req) + if not result.passed: + raise TaskCompletenessError( + missing=result.missing, + field_hints=result.field_hints, + message=( + "create_subtask: task missing required fields: " + f"{result.missing}. The silent fallback at " + "services/task.py:5061 was removed 2026-05-10 (spec §5.2)." + ), + ) + inferred_status = TaskStatus.PENDING if req.assigned_to else TaskStatus.BACKLOG prepared = TaskCreateRequest( title=req.title, description=req.description, - acceptance_criteria=req.acceptance_criteria - or ["completed and reviewed by assignee"], + acceptance_criteria=req.acceptance_criteria, team=req.team, created_by=req.created_by, project_id=req.project_id, @@ -5067,6 +5093,7 @@ class TaskService(BaseService): assigned_to=req.assigned_to, estimated_complexity=req.estimated_complexity, task_type=req.task_type, + nature=req.nature, status=req.status or inferred_status, ) return await self.create(prepared) diff --git a/scripts/build_lifecycle_artifacts.py b/scripts/build_lifecycle_artifacts.py new file mode 100755 index 00000000..6bf632c1 --- /dev/null +++ b/scripts/build_lifecycle_artifacts.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Regenerate lifecycle artifacts from roboco/foundation/policy/lifecycle.py. + +Outputs (deterministic): + - docs/rag/lifecycle/intent-verbs.md + - docs/rag/lifecycle/status-transitions.md + - panel/lib/lifecycle.json + - agents/prompts/_generated/lifecycle-{role}.md (one per role) + +Run as part of `make lifecycle`. CI gate: `make lifecycle && git diff +--exit-code` fails if regeneration produces a diff. +""" + +from __future__ import annotations + +from pathlib import Path + +from roboco.foundation import _generators +from roboco.foundation.policy.lifecycle import Role + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + print(f"wrote {path.relative_to(REPO_ROOT)}") + + +def main() -> int: + write( + REPO_ROOT / "docs" / "rag" / "lifecycle" / "intent-verbs.md", + _generators.render_intent_verbs_md(), + ) + write( + REPO_ROOT / "docs" / "rag" / "lifecycle" / "status-transitions.md", + _generators.render_status_transitions_md(), + ) + write( + REPO_ROOT / "panel" / "lib" / "lifecycle.json", + _generators.render_panel_json(), + ) + for role in Role: + write( + REPO_ROOT + / "agents" + / "prompts" + / "_generated" + / f"lifecycle-{role.value}.md", + _generators.render_agent_prompt_fragment(role.value), + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_postgres_enums.py b/scripts/verify_postgres_enums.py new file mode 100644 index 00000000..c7fbe709 --- /dev/null +++ b/scripts/verify_postgres_enums.py @@ -0,0 +1,92 @@ +"""Verify postgres agentrole + team enums match foundation/identity. + +Run: `uv run python scripts/verify_postgres_enums.py` + +Exits 0 on match, 1 on drift (with a diff). If drift, an Alembic +migration must be written before Phase 1 can complete. + +Reads DB connection from roboco.config (same env as the orchestrator). +On connection failure (postgres unreachable), prints a clear message +and exits 1 — callers (e.g. `make foundation-check`) handle that as a +"skip" rather than a hard failure. +""" + +from __future__ import annotations + +import asyncio +import sys + +import asyncpg +from roboco.config import settings +from roboco.foundation import identity + + +async def fetch_enum_values(conn: asyncpg.Connection, enum_name: str) -> set[str]: + rows = await conn.fetch( + """ + SELECT e.enumlabel + FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = $1 + ORDER BY e.enumsortorder + """, + enum_name, + ) + return {r["enumlabel"] for r in rows} + + +async def main() -> int: + try: + conn = await asyncpg.connect( + host=settings.database_host, + port=settings.database_port, + user=settings.database_user, + password=settings.database_password, + database=settings.database_name, + ) + except (OSError, asyncpg.PostgresError) as exc: + print(f"postgres unreachable: {exc}") + print( + "Run this verifier from an environment with the orchestrator DB available." + ) + return 1 + try: + agentrole_db = await fetch_enum_values(conn, "agentrole") + team_db = await fetch_enum_values(conn, "team") + finally: + await conn.close() + + foundation_roles = {r.value for r in identity.Role} + foundation_teams = {t.value for t in identity.Team} + + drift = False + missing_roles_in_db = foundation_roles - agentrole_db + extra_roles_in_db = agentrole_db - foundation_roles + missing_teams_in_db = foundation_teams - team_db + extra_teams_in_db = team_db - foundation_teams + + if missing_roles_in_db: + print(f"DRIFT: postgres agentrole missing: {sorted(missing_roles_in_db)}") + drift = True + if extra_roles_in_db: + print(f"DRIFT: postgres agentrole has extra: {sorted(extra_roles_in_db)}") + drift = True + if missing_teams_in_db: + print(f"DRIFT: postgres team missing: {sorted(missing_teams_in_db)}") + drift = True + if extra_teams_in_db: + print(f"DRIFT: postgres team has extra: {sorted(extra_teams_in_db)}") + drift = True + + if drift: + print("Write an Alembic migration to align postgres with foundation.") + return 1 + + print( + f"OK: agentrole has {len(agentrole_db)} values; team has {len(team_db)} values." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/fixtures/2026-05-08-smoke-trace.json b/tests/fixtures/2026-05-08-smoke-trace.json new file mode 100644 index 00000000..1b88a44c --- /dev/null +++ b/tests/fixtures/2026-05-08-smoke-trace.json @@ -0,0 +1,194 @@ +{ + "schema_version": 1, + "trace_date": "2026-05-08", + "follow_up_trace_date": "2026-05-09", + "synthesis_note": "The original audit-log SQL dump from /tmp/audit-trace.txt on the NAS was wiped during cleanup before the spec-migration session. This fixture is a documented synthesis of the bug shapes captured in the prior session's commit messages and analysis, NOT a verbatim event-by-event replay. Each record below is faithful to one bug observed in the trace and pins (verb, role, task setup, expected post-fix envelope shape) so the spec can never silently re-introduce the bug.", + "known_bugs": [ + { + "id": "bug-1-pm-cannot-execute-code-on-i-will-plan", + "trace_date": "2026-05-08", + "title": "pm_cannot_execute_code fired on i_will_plan, deadlocking code-typed parents", + "summary": "Pre-fix the pm_cannot_execute_code guard rejected i_will_plan against code-typed parent tasks. Cell PMs PLAN code-typed parents and DELEGATE the code execution; rejecting the planning verb deadlocked the slice. Fix: scope the guard to i_will_work_on (the EXECUTION verb) only.", + "verb": "i_will_plan", + "role": "cell_pm", + "task_status": "pending", + "task_type": "code", + "context": {"plan": "plan the backend slice"}, + "expected_post_fix_decision": "allow", + "expected_post_fix_envelope_error": null, + "fix_commit": "504b553", + "spec_invariant": "i_will_plan.allowed_roles must include cell_pm and main_pm regardless of task_type; per-role-vs-task_type narrowing only applies to i_will_work_on (the execution verb).", + "replay_kind": "spec_decision" + }, + { + "id": "bug-2-delegate-magic-task-type-default", + "trace_date": "2026-05-08", + "title": "delegate.task_type silently defaulted to 'code' when caller omitted it", + "summary": "main-pm called delegate without task_type; the schema's default of 'code' silently changed the semantics. The downstream cell PM ended up with a code-typed task they couldn't legitimately own. Fix: task_type is REQUIRED on DelegateRequest at the HTTP boundary (HTTP 422) and on the choreographer DelegateInputs dataclass.", + "verb": "delegate", + "role": "main_pm", + "task_status": "in_progress", + "task_type": null, + "context": {}, + "expected_post_fix_decision": "rejected_at_http_boundary", + "expected_post_fix_envelope_error": null, + "fix_commit": "504b553", + "spec_invariant": "DelegateRequest.task_type is required (HTTP 422 at boundary); choreographer never receives a defaulted value. The spec itself doesn't gate task_type at delegate; it's enforced at the schema layer.", + "replay_kind": "schema_only_skip", + "skip_reason": "Bug is enforced at the Pydantic/HTTP schema boundary, not at the choreographer or spec layer. There is no spec-level Decision to assert. Documented for completeness; replay test skips." + }, + { + "id": "bug-3-silent-partial-submit-for-qa", + "trace_date": "2026-05-08", + "title": "submit_for_qa returned OK after opening PR but agent never called i_am_done", + "summary": "The verb name 'submit_for_qa' implied the QA handoff was complete after the PR opened. Agents read the verb name, never called i_am_done, and PRs ended up orphaned (PR #12 in the trace). Fix: rename submit_for_qa -> open_pr; the actual QA handoff happens at i_am_done. Atomic preconditions (assignee, commits, no-prior-PR) checked BEFORE git.create_pr/push_branch.", + "verb": "open_pr", + "role": "developer", + "task_status": "in_progress", + "task_type": "code", + "context": {"owns_task": true, "commits": 1, "pr_number": null}, + "expected_post_fix_decision": "allow", + "expected_post_fix_envelope_error": null, + "fix_commit": "5a10ae9", + "spec_invariant": "open_pr's IntentSpec exists in _INTENT_VERBS (the rename landed); composes=() with side_effects=('push_branch','create_pr'); extra_preconditions=(PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS, PRECONDITION_NO_PR). submit_for_qa is NOT in the verb set.", + "replay_kind": "spec_introspection" + }, + { + "id": "bug-4-scattered-role-checks-disagreed", + "trace_date": "2026-05-08", + "title": "Three places held role x state tables that could disagree silently", + "summary": "Pre-fix, role checks lived in role_config.py, claim_guards.py, and Choreographer string constants in _impl.py / qa.py / doc.py / content_actions.py. They could (and did) drift. Fix: collapse into a single declarative table — verb_gates.py initially (Task 81ffc16), now lifecycle.spec (the canonical migration). Every consumer derives behavior from spec.intents_for_role / spec.can_invoke_intent / spec.valid_next_verbs.", + "verb": "*", + "role": "*", + "task_status": "*", + "task_type": "*", + "context": {}, + "expected_post_fix_decision": "single_source_of_truth", + "expected_post_fix_envelope_error": null, + "fix_commit": "81ffc16", + "spec_invariant": "spec.intents_for_role(role) returns the same verb set as role_config.FLOW_TOOLS_BY_ROLE; verb_gates.py is deleted (commit bdeedd8); choreographer verbs all gate via spec.can_invoke_intent (no scattered role-string lists).", + "replay_kind": "spec_introspection" + }, + { + "id": "bug-5-i-will-plan-not-idempotent-after-restart", + "trace_date": "2026-05-08", + "title": "i_will_plan rejected re-entry after orchestrator restart, agent had no recovery path", + "summary": "Spawn 1: i_will_plan succeeds, task pending -> claimed -> in_progress. Container exits, agent respawns. Spawn 2: i_will_plan called again on a task already in in_progress; pre-fix preflight rejected with 'task in in_progress, expected pending'. Agent had no recovery; reaper eventually dropped the claim, spawn 3 fired, infinite loop. Fix: idempotent re-entry — if respawned PM/dev re-enters on a task they already own in claimed/in_progress, return OK with current state and refresh heartbeat.", + "verb": "i_will_plan", + "role": "cell_pm", + "task_status": "in_progress", + "task_type": "planning", + "context": {"owns_task": true, "plan": "still my plan"}, + "expected_post_fix_decision": "verb_body_idempotent_returns_ok", + "expected_post_fix_envelope_error": null, + "fix_commit": "e51ba30", + "spec_invariant": "Idempotent re-entry is a verb-body concern (Choreographer-level guard the spec does not model). Spec-level: i_will_plan with composes=('claim','set_plan','start') would reject from in_progress because claim's source_statuses don't include in_progress. The verb body intentionally bypasses the spec gate when the caller is the current owner.", + "replay_kind": "behavioral_skip", + "skip_reason": "Idempotent re-entry is a Choreographer-level behavioral guard, not a spec-level Decision. Pinned by separate unit tests in test_choreographer_pm_extras. Spec gate rejection is the EXPECTED first-line behavior; the verb body's owner-shortcut is what makes the verb idempotent." + }, + { + "id": "bug-6-not-your-claim-after-restart", + "trace_date": "2026-05-08", + "title": "'not your claim' rejection on unclaim/resume gave no current_owner hint", + "summary": "main-pm hit 'not your claim' on unclaim/resume at 02:51:22 / 02:52:48 because an upstream verb (cell_pm_complete propagating up via _maybe_advance_parent_to_pm_review, or main_pm_complete clearing assigned_to to None, or unblock with restore=True) reassigned the task. Pre-fix the rejection said only 'not your claim'; agents couldn't tell if it was a transient race or a legitimate move. Fix: surface current_owner UUID and hint that an upstream verb did this.", + "verb": "unclaim", + "role": "main_pm", + "task_status": "in_progress", + "task_type": "planning", + "context": {"actor_is_owner": false, "task_was_reassigned": true}, + "expected_post_fix_decision": "verb_body_includes_current_owner_hint", + "expected_post_fix_envelope_error": "invalid_state", + "fix_commit": "a5d358d", + "spec_invariant": "unclaim's IntentSpec has composes=() and no extra_preconditions; the spec gate is role-only. The reassignment-rejection branch with current_owner hint is a Choreographer-level guard the spec does not model. Spec invariant: role gate must allow main_pm to call unclaim regardless of actual ownership; the verb body's current_owner-hinted rejection is then the load-bearing UX fix.", + "replay_kind": "spec_decision_role_only" + }, + { + "id": "bug-7-plan-sdk-vs-gate-disagreement", + "trace_date": "2026-05-08", + "title": "Agent SDK / verb-gate disagreement on what to do after submit_for_qa", + "summary": "Pre-fix the agent_sdk post-tool guidance map said one thing about next= after submit_for_qa, while the gateway envelope's next-hint said another. The verb's name ('submit_for_qa') reinforced the wrong mental model. Coupled with the rename of submit_for_qa -> open_pr, the SDK guidance, role_config flow manifest, runtime orchestrator prompts, and generated agent prompts were all updated in lockstep. Fix: single rename across all surfaces; the spec is now the source of truth via spec.intents_for_role + spec generators.", + "verb": "open_pr", + "role": "developer", + "task_status": "in_progress", + "task_type": "code", + "context": {}, + "expected_post_fix_decision": "single_source_of_truth", + "expected_post_fix_envelope_error": null, + "fix_commit": "5a10ae9", + "spec_invariant": "open_pr is in _INTENT_VERBS and submit_for_qa is NOT. spec.intents_for_role(Role.DEVELOPER) returns open_pr (and not submit_for_qa). All agent-facing surfaces (SDK guidance map, role_config flow lists, agent prompts) are derived from this spec.", + "replay_kind": "spec_introspection" + }, + { + "id": "bug-8-audit-role-wrong", + "trace_date": "2026-05-08", + "title": "audit_log.agent_role recorded the verb's expected role, not the actor's actual role", + "summary": "The trace caught an audit row with actor=main-pm but agent_role=cell_pm. The caller had supplied the verb's *expected* role rather than the actor's actual role. Forensics joining audit_log on agent_role would silently miscategorize. Fix: AuditService now reads the actor's role directly from agents.role at write time via _resolve_actor_role_from_db.", + "verb": "*", + "role": "*", + "task_status": "*", + "task_type": "*", + "context": {}, + "expected_post_fix_decision": "audit_layer_concern", + "expected_post_fix_envelope_error": null, + "fix_commit": "7a2d4e3", + "spec_invariant": "Spec layer does not model audit. Bug is a side-channel data integrity concern in roboco.services.audit; fix lives below the spec.", + "replay_kind": "audit_only_skip", + "skip_reason": "Audit-layer concern below the lifecycle spec. The fix is in roboco.services.audit; pinned by tests in tests/unit/services/test_audit.py and test_audit_real_query.py." + }, + { + "id": "bug-9-notfounderror-500s", + "trace_date": "2026-05-08", + "title": "agent_id null on task.* and agent.* audit events", + "summary": "task.awaiting_qa fired AFTER submit_qa cleared claimed_by; the audit row therefore had agent_id=null. agent.* events stored slug-only. Forensics by agent_id missed these rows. Fix: capture claimed_by BEFORE mutation and add slug->UUID resolver in orchestrator audit path. (Categorized as 'NotFoundError 500s' colloquially because the missing-id-then-lookup pattern surfaced as 500s in some downstream consumers.)", + "verb": "*", + "role": "*", + "task_status": "*", + "task_type": "*", + "context": {}, + "expected_post_fix_decision": "audit_layer_concern", + "expected_post_fix_envelope_error": null, + "fix_commit": "e9d53fb", + "spec_invariant": "Spec layer does not model audit-event field population. Bug is a data-capture ordering concern in roboco.services.audit + roboco.services.task; fix lives below the spec.", + "replay_kind": "audit_only_skip", + "skip_reason": "Audit-layer concern below the lifecycle spec. The fix is in roboco.services.audit + roboco.services.task; pinned by tests in tests/unit/services/test_audit_agent_id.py." + }, + { + "id": "bug-a-claim-before-plan-check", + "trace_date": "2026-05-09", + "title": "i_will_work_on without plan partially claimed (claim ran before plan check)", + "summary": "be-dev-1 called i_will_work_on without `plan` on a pending task. claim() ran first (transitioned to claimed), then the plan check failed with tracing_gap. The natural retry path then dispatched to _i_will_work_on_claimed, which had no plan-recovery logic and called start() against a still-plan-less task, returning 'start failed' forever. Dev kept looping; parent escalated up; whole slice ended blocked. Fix (Task-5 atomicity pattern): plan precondition runs BEFORE claim(); a missing-plan first call now returns tracing_gap with the task untouched in pending.", + "verb": "i_will_work_on", + "role": "developer", + "task_status": "pending", + "task_type": "code", + "context": {"plan": null}, + "expected_post_fix_decision": "tracing_gap", + "expected_post_fix_envelope_error": "tracing_gap", + "expected_missing": ["plan"], + "fix_commit": "1e4c7a8", + "spec_invariant": "i_will_work_on's IntentSpec has extra_preconditions=(PRECONDITION_PLAN,). spec.can_invoke_intent rejects with rejection_kind='tracing_gap' and missing=['plan'] BEFORE any composed action is attempted. The verb body must run the spec gate before any DB mutation (claim+set_plan+start are atomic per Task 10's VerbRunner).", + "replay_kind": "spec_decision" + }, + { + "id": "bug-b-cell-pm-gets-code-typed-task", + "trace_date": "2026-05-09", + "title": "main-pm delegated a code-typed task to a Cell PM (mis-typed subtask)", + "summary": "main-pm called delegate(assigned_to='be-pm', task_type='code'). The chain validator let it through (be-pm IS in main-pm's allowed targets), the schema let it through (task_type='code' is a valid enum value), and the subtask was created mis-typed. Task 0 made it cosmetically work (PMs can plan code-typed parents) but the model is wrong: a Cell PM owns the PLANNING of the slice; code execution is delegated to devs. Fix: new gate in _delegate_static_guards — when assignee is a Cell PM (be-pm/fe-pm/ux-pm), task_type MUST be 'planning'. Returns invalid_state with a remediate hint pointing at the right type. Devs are unrestricted.", + "verb": "delegate", + "role": "main_pm", + "task_status": "in_progress", + "task_type": "planning", + "delegate_inputs": { + "assigned_to": "be-pm", + "team": "backend", + "task_type": "code" + }, + "context": {}, + "expected_post_fix_decision": "verb_body_invalid_state", + "expected_post_fix_envelope_error": "invalid_state", + "fix_commit": "dfbcb3e", + "spec_invariant": "delegate's IntentSpec gates role+state at the spec layer (_PM_ROLES, parent task_status==in_progress). The Cell-PM-vs-task-type narrowing is a verb-body guard (_delegate_static_guards) the spec does not directly model; documented as a spec-vs-canon diff in the design doc. The role+state gate must STILL allow the verb to enter so the verb-body gate can fire and surface invalid_state with the right remediate.", + "replay_kind": "spec_decision_then_verb_body" + } + ] +} diff --git a/tests/foundation/__init__.py b/tests/foundation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/foundation/test_agent_loop.py b/tests/foundation/test_agent_loop.py new file mode 100644 index 00000000..a04ba05b --- /dev/null +++ b/tests/foundation/test_agent_loop.py @@ -0,0 +1,96 @@ +"""Tier 1 — agent_loop budgets + verb retry limits.""" + +from __future__ import annotations + +import os + +import pytest +from roboco.agent_sdk import server +from roboco.foundation.policy import agent_loop + +# Canonical defaults the foundation guarantees. Asserting against named +# constants keeps the contract explicit (and keeps PLR2004 happy — these +# are not magic numbers, they are the public API surface). +EXPECTED_TOOL_CALL_WARN_AT = 50 +EXPECTED_TOOL_CALL_HALT_AT = 150 +EXPECTED_LOOP_THRESHOLD = 3 +EXPECTED_LOOP_WINDOW = 10 +EXPECTED_PM_RESPAWN_MAX_UNPRODUCTIVE = 3 +EXPECTED_VERB_RETRY_MAX_PER_MINUTE = 3 +EXPECTED_HANDOFF_VERB_CAP = 3 + + +def test_default_budget_has_canonical_thresholds() -> None: + b = agent_loop.DEFAULT_BUDGET + assert b.tool_call_warn_at == EXPECTED_TOOL_CALL_WARN_AT + assert b.tool_call_halt_at == EXPECTED_TOOL_CALL_HALT_AT + assert b.loop_threshold == EXPECTED_LOOP_THRESHOLD + assert b.loop_window == EXPECTED_LOOP_WINDOW + assert b.loop_action == "halt" # NEW: was "warn-only" + assert b.pm_respawn_max_unproductive == EXPECTED_PM_RESPAWN_MAX_UNPRODUCTIVE + assert b.verb_retry_max_per_minute == EXPECTED_VERB_RETRY_MAX_PER_MINUTE + + +def test_warn_threshold_below_halt_threshold() -> None: + assert ( + agent_loop.DEFAULT_BUDGET.tool_call_warn_at + < agent_loop.DEFAULT_BUDGET.tool_call_halt_at + ) + + +def test_loop_threshold_below_loop_window() -> None: + """Loop detection requires N repeats in a window of M; N must be < M.""" + assert ( + agent_loop.DEFAULT_BUDGET.loop_threshold < agent_loop.DEFAULT_BUDGET.loop_window + ) + + +def test_verb_retry_limits_cover_critical_handoff_verbs() -> None: + """The verbs that surfaced in the 2026-05-10 retry storm are capped.""" + assert agent_loop.VERB_RETRY_LIMITS["i_am_done"] == EXPECTED_HANDOFF_VERB_CAP + assert agent_loop.VERB_RETRY_LIMITS["complete"] == EXPECTED_HANDOFF_VERB_CAP + assert agent_loop.VERB_RETRY_LIMITS["submit_up"] == EXPECTED_HANDOFF_VERB_CAP + + +def test_unlimited_retry_verbs_includes_discovery_verbs() -> None: + """give_me_work / triage / evidence aren't subject to circuit breaker.""" + assert "give_me_work" in agent_loop.UNLIMITED_RETRY_VERBS + assert "triage" in agent_loop.UNLIMITED_RETRY_VERBS + assert "evidence" in agent_loop.UNLIMITED_RETRY_VERBS + assert "i_am_idle" in agent_loop.UNLIMITED_RETRY_VERBS + + +def test_retry_limit_for_known_verb_returns_int() -> None: + assert agent_loop.retry_limit_for("i_am_done") == EXPECTED_HANDOFF_VERB_CAP + + +def test_retry_limit_for_unlimited_verb_returns_none() -> None: + assert agent_loop.retry_limit_for("give_me_work") is None + + +def test_retry_limit_for_unknown_verb_returns_default() -> None: + """Unknown verbs fall through to the policy default.""" + assert ( + agent_loop.retry_limit_for("not_a_real_verb") + == agent_loop.DEFAULT_BUDGET.verb_retry_max_per_minute + ) + + +def test_agent_sdk_thresholds_match_foundation_defaults() -> None: + """Without env overrides, the agent_sdk constants equal foundation defaults.""" + # If a test environment sets ROBOCO_AGENT_*, the env override path is in + # effect and this default-parity check no longer applies. + if any( + k in os.environ + for k in ( + "ROBOCO_AGENT_TOOL_CALL_WARN", + "ROBOCO_AGENT_TOOL_CALL_HALT", + "ROBOCO_AGENT_LOOP_THRESHOLD", + "ROBOCO_AGENT_LOOP_WINDOW", + ) + ): + pytest.skip("env override set; skipping default-parity check") + assert agent_loop.DEFAULT_BUDGET.tool_call_warn_at == server._WARN_THRESHOLD + assert agent_loop.DEFAULT_BUDGET.tool_call_halt_at == server._HALT_THRESHOLD + assert agent_loop.DEFAULT_BUDGET.loop_threshold == server._LOOP_THRESHOLD + assert agent_loop.DEFAULT_BUDGET.loop_window == server._LOOP_WINDOW diff --git a/tests/foundation/test_agents_config_parity.py b/tests/foundation/test_agents_config_parity.py new file mode 100644 index 00000000..a9265bf8 --- /dev/null +++ b/tests/foundation/test_agents_config_parity.py @@ -0,0 +1,51 @@ +"""Verify agents_config.* tables are derived from foundation, not duplicated. + +The migration replaces the hand-maintained constants in agents_config with +expressions that compute from foundation.AGENTS. After this task: +- AGENT_ROLE_MAP[slug] == foundation.role_for_slug(slug).value (string-typed) +- AGENT_TEAM_MAP[slug] == foundation.team_for_slug(slug).value +- CELL_MEMBERS[team_value] == sorted(foundation.slugs_for_team(...)) +""" + +from __future__ import annotations + +from roboco.agents_config import AGENT_ROLE_MAP, AGENT_TEAM_MAP, CELL_MEMBERS +from roboco.foundation import identity + + +def test_agent_role_map_matches_foundation() -> None: + for slug, role_str in AGENT_ROLE_MAP.items(): + assert role_str == identity.role_for_slug(slug).value, ( + f"role drift for {slug!r}: agents_config has {role_str!r}, " + f"foundation has {identity.role_for_slug(slug).value!r}" + ) + # Every slug in foundation (except system sentinel) appears in AGENT_ROLE_MAP. + foundation_slugs = set(identity.AGENTS) - {"system"} + config_slugs = set(AGENT_ROLE_MAP) + assert foundation_slugs <= config_slugs, ( + f"slugs in foundation but not agents_config: {foundation_slugs - config_slugs}" + ) + + +def test_agent_team_map_matches_foundation() -> None: + for slug, team_str in AGENT_TEAM_MAP.items(): + assert team_str == identity.team_for_slug(slug).value, ( + f"team drift for {slug!r}: agents_config={team_str!r}, " + f"foundation={identity.team_for_slug(slug).value!r}" + ) + + +def test_cell_members_matches_foundation() -> None: + """CELL_MEMBERS keys are team-strings; values are sorted slug lists.""" + for team in (identity.Team.BACKEND, identity.Team.FRONTEND, identity.Team.UX_UI): + config_members = CELL_MEMBERS.get(team.value, []) + foundation_members = sorted(identity.slugs_for_team(team)) + assert sorted(config_members) == foundation_members, ( + f"cell members drift for {team.value!r}: " + f"config={sorted(config_members)}, foundation={foundation_members}" + ) + + +def test_head_marketing_team_is_board_in_agents_config() -> None: + """Resolves the head-marketing.team drift via the foundation derivation.""" + assert AGENT_TEAM_MAP["head-marketing"] == "board" diff --git a/tests/foundation/test_communications.py b/tests/foundation/test_communications.py new file mode 100644 index 00000000..68ede25a --- /dev/null +++ b/tests/foundation/test_communications.py @@ -0,0 +1,143 @@ +"""Tier 1 — communications policy: Priority + sender allowlist + ack-required.""" + +from __future__ import annotations + +import dataclasses + +from roboco.agents_config import CHANNEL_ACCESS +from roboco.foundation import identity +from roboco.foundation.policy import communications +from roboco.models.base import NotificationPriority, NotificationType + + +def test_priority_enum_matches_notification_priority() -> None: + """Communications.Priority is a re-export of NotificationPriority.""" + assert communications.Priority is NotificationPriority + + +def test_notify_sender_roles_includes_pms_and_board_and_ceo() -> None: + expected = frozenset( + { + identity.Role.CELL_PM, + identity.Role.MAIN_PM, + identity.Role.PRODUCT_OWNER, + identity.Role.HEAD_MARKETING, + identity.Role.CEO, + } + ) + assert expected == communications.NOTIFY_SENDER_ROLES + + +def test_notify_sender_roles_excludes_auditor() -> None: + """Auditor is silent — no notification sending.""" + assert identity.Role.AUDITOR not in communications.NOTIFY_SENDER_ROLES + + +def test_ack_required_by_type_covers_every_notification_type() -> None: + for nt in NotificationType: + assert nt in communications.ACK_REQUIRED_BY_TYPE, ( + f"{nt.value} missing from ACK_REQUIRED_BY_TYPE" + ) + + +def test_ack_required_for_blocker_escalation() -> None: + assert ( + communications.ACK_REQUIRED_BY_TYPE[NotificationType.BLOCKER_ESCALATION] is True + ) + + +def test_ack_not_required_for_task_assignment() -> None: + assert ( + communications.ACK_REQUIRED_BY_TYPE[NotificationType.TASK_ASSIGNMENT] is False + ) + + +def test_channel_spec_dataclass_is_frozen() -> None: + assert dataclasses.is_dataclass(communications.ChannelSpec) + fields = {f.name for f in dataclasses.fields(communications.ChannelSpec)} + assert { + "slug", + "description", + "type", + "read_roles", + "write_roles", + "silent_roles", + "read_only_for_others", + } <= fields + + +def test_channels_dict_non_empty() -> None: + assert len(communications.CHANNELS) > 0 + + +def test_channels_keys_match_agents_config_channel_access() -> None: + """CHANNELS must include every channel from the legacy CHANNEL_ACCESS data.""" + legacy_slugs = set(CHANNEL_ACCESS.keys()) + foundation_slugs = set(communications.CHANNELS.keys()) + # Allow foundation to be a SUPERSET (new channels OK) but every + # legacy channel must be represented: + missing = legacy_slugs - foundation_slugs + assert missing == set(), f"channels in agents_config not in foundation: {missing}" + + +def test_silent_roles_subset_of_read_roles_for_every_channel() -> None: + for slug, spec in communications.CHANNELS.items(): + assert spec.silent_roles <= spec.read_roles, ( + f"{slug}: silent_roles {spec.silent_roles} not subset of " + f"read_roles {spec.read_roles}" + ) + + +def test_announcements_is_read_only_for_others() -> None: + """Spec §5.5: announcements is the canonical read-only channel.""" + if "announcements" in communications.CHANNELS: + spec = communications.CHANNELS["announcements"] + assert spec.read_only_for_others is True + + +def test_backend_cell_has_canonical_membership() -> None: + if "backend-cell" in communications.CHANNELS: + spec = communications.CHANNELS["backend-cell"] + assert identity.Role.DEVELOPER in spec.read_roles + assert identity.Role.AUDITOR in spec.silent_roles + + +# ----- parse_priority ------------------------------------------------------- + + +def test_parse_priority_recognizes_string_normal() -> None: + assert communications.parse_priority("normal") is communications.Priority.NORMAL + + +def test_parse_priority_recognizes_string_high() -> None: + assert communications.parse_priority("high") is communications.Priority.HIGH + + +def test_parse_priority_recognizes_string_urgent() -> None: + assert communications.parse_priority("urgent") is communications.Priority.URGENT + + +def test_parse_priority_unknown_string_falls_back_to_normal() -> None: + assert ( + communications.parse_priority("definitely-not-real") + is communications.Priority.NORMAL + ) + + +def test_parse_priority_legacy_urgent_flag_maps_to_urgent() -> None: + assert ( + communications.parse_priority(None, legacy_urgent_flag=True) + is communications.Priority.URGENT + ) + + +def test_parse_priority_default_is_normal() -> None: + assert communications.parse_priority(None) is communications.Priority.NORMAL + + +def test_parse_priority_explicit_priority_wins_over_legacy_flag() -> None: + """Spec §5.5 precedence: priority string beats legacy urgent bool.""" + assert ( + communications.parse_priority("normal", legacy_urgent_flag=True) + is communications.Priority.NORMAL + ) diff --git a/tests/foundation/test_communications_consumers.py b/tests/foundation/test_communications_consumers.py new file mode 100644 index 00000000..b4fbb7d5 --- /dev/null +++ b/tests/foundation/test_communications_consumers.py @@ -0,0 +1,196 @@ +"""Verify communications-policy consumers derive from foundation.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +from roboco.agents_config import CHANNEL_ACCESS +from roboco.foundation.identity import AGENTS, Role, Team +from roboco.foundation.policy import communications +from roboco.foundation.policy.communications import ChannelSpec +from roboco.seeds.initial_data import ( + AUDITOR_SILENT_ACCESS, + CHANNEL_MEMBERSHIPS, + DEFAULT_CHANNELS, +) +from roboco.services.gateway import content_actions + +# Cell-member roles that are subject to a channel's team_scope. Cross-cell +# roles (MAIN_PM, AUDITOR, CEO, board) are NOT filtered — they participate +# regardless of which team a cell channel scopes to. +_TEAM_SCOPED_ROLES: frozenset[Role] = frozenset( + {Role.DEVELOPER, Role.QA, Role.DOCUMENTER, Role.CELL_PM} +) + + +def _expected_slugs(role_set: frozenset[Role], team_scope: Team | None) -> set[str]: + """Expand a role-set to slugs, honoring an optional team_scope. + + A slug qualifies when its role is in `role_set` AND, if the role is in + _TEAM_SCOPED_ROLES and team_scope is set, its team matches team_scope. + System sentinel is always excluded. + """ + out: set[str] = set() + for slug, row in AGENTS.items(): + if slug == "system": + continue + if row.role not in role_set: + continue + if ( + team_scope is not None + and row.role in _TEAM_SCOPED_ROLES + and row.team != team_scope + ): + continue + out.add(slug) + return out + + +def test_agents_config_channel_access_keys_match_foundation_channels() -> None: + """Every channel slug in CHANNEL_ACCESS is present in CHANNELS.""" + legacy_slugs = set(CHANNEL_ACCESS.keys()) + foundation_slugs = set(communications.CHANNELS.keys()) + assert legacy_slugs == foundation_slugs, ( + f"slug drift: legacy_only={legacy_slugs - foundation_slugs}, " + f"foundation_only={foundation_slugs - legacy_slugs}" + ) + + +def test_channel_access_read_membership_derives_from_foundation_role_to_slug() -> None: + """CHANNEL_ACCESS[slug]['read'] == every agent slug whose role is in + foundation.CHANNELS[slug].read_roles minus silent_roles, filtered by + team_scope when set. Legacy semantics keep silent observers in the + 'silent' bucket and out of 'read'; the access check treats them as + read-allowed at runtime.""" + for slug, spec in communications.CHANNELS.items(): + cfg_read = set(CHANNEL_ACCESS[slug]["read"]) + active_read_roles = spec.read_roles - spec.silent_roles + expected_read = _expected_slugs(active_read_roles, spec.team_scope) + assert cfg_read == expected_read, ( + f"{slug} read drift: cfg={sorted(cfg_read)} " + f"expected={sorted(expected_read)}" + ) + + +def test_channel_access_write_membership_derives_from_foundation() -> None: + """Same for write_roles.""" + for slug, spec in communications.CHANNELS.items(): + cfg_write = set(CHANNEL_ACCESS[slug]["write"]) + expected_write = _expected_slugs(spec.write_roles, spec.team_scope) + assert cfg_write == expected_write, ( + f"{slug} write drift: cfg={sorted(cfg_write)} " + f"expected={sorted(expected_write)}" + ) + + +def test_channel_access_silent_membership_derives_from_foundation() -> None: + """CHANNEL_ACCESS[slug]['silent'] == slugs derived from silent_roles.""" + for slug, spec in communications.CHANNELS.items(): + cfg_silent = set(CHANNEL_ACCESS[slug]["silent"]) + expected_silent = _expected_slugs(spec.silent_roles, spec.team_scope) + assert cfg_silent == expected_silent, ( + f"{slug} silent drift: cfg={sorted(cfg_silent)} " + f"expected={sorted(expected_silent)}" + ) + + +def test_channelspec_dataclass_exposes_team_scope() -> None: + """ChannelSpec must carry team_scope so cell channels can scope membership.""" + fields = {f.name for f in ChannelSpec.__dataclass_fields__.values()} + assert "team_scope" in fields + + +def test_seed_default_channels_match_foundation_slugs() -> None: + seed_slugs = {ch["slug"] for ch in DEFAULT_CHANNELS} + foundation_slugs = set(communications.CHANNELS) + assert seed_slugs == foundation_slugs, ( + f"seed/foundation slug drift: {seed_slugs ^ foundation_slugs}" + ) + + +def test_seed_default_channels_descriptions_match_foundation() -> None: + """Description text comes from the foundation ChannelSpec.description.""" + by_slug = {ch["slug"]: ch for ch in DEFAULT_CHANNELS} + for slug, spec in communications.CHANNELS.items(): + assert by_slug[slug].get("description") == spec.description, ( + f"{slug} description drift: " + f"seed={by_slug[slug].get('description')!r} " + f"foundation={spec.description!r}" + ) + + +def test_channel_memberships_derives_from_foundation_role_to_slug() -> None: + """CHANNEL_MEMBERSHIPS[slug] == sorted slugs whose role is in + CHANNELS[slug].read_roles, filtered by team_scope when set.""" + for slug, spec in communications.CHANNELS.items(): + seed_members = set(CHANNEL_MEMBERSHIPS.get(slug, [])) + team_scope = getattr(spec, "team_scope", None) + expected = _expected_slugs(spec.read_roles, team_scope) + assert seed_members == expected, ( + f"{slug} membership drift: seed={sorted(seed_members)} " + f"expected={sorted(expected)}" + ) + + +def test_auditor_silent_access_derives_from_foundation() -> None: + """AUDITOR_SILENT_ACCESS == channels where AUDITOR is in silent_roles.""" + expected = { + slug + for slug, spec in communications.CHANNELS.items() + if Role.AUDITOR in spec.silent_roles + } + assert set(AUDITOR_SILENT_ACCESS) == expected, ( + f"auditor silent drift: seed={sorted(AUDITOR_SILENT_ACCESS)} " + f"expected={sorted(expected)}" + ) + + +def test_content_actions_notify_allowed_roles_matches_foundation() -> None: + cfg_set = { + r if isinstance(r, str) else r.value + for r in content_actions._NOTIFY_ALLOWED_ROLES + } + foundation_set = {r.value for r in communications.NOTIFY_SENDER_ROLES} + assert cfg_set == foundation_set, ( + f"_NOTIFY_ALLOWED_ROLES drift: cfg={cfg_set} foundation={foundation_set}" + ) + + +def test_content_actions_valid_priorities_matches_foundation() -> None: + cfg = set(content_actions._VALID_NOTIFY_PRIORITIES) + foundation = {p.value for p in communications.Priority} + assert cfg == foundation + + +def test_notification_delivery_uses_ack_required_table() -> None: + """All NotificationTable() construction sites must source `requires_ack` + from ACK_REQUIRED_BY_TYPE, not from a hand-set boolean literal.""" + src = Path("roboco/services/notification_delivery.py").read_text() + tree = ast.parse(src) + + offenders: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + callee = node.func + callee_name = ( + callee.attr + if isinstance(callee, ast.Attribute) + else callee.id + if isinstance(callee, ast.Name) + else None + ) + if callee_name != "NotificationTable": + continue + for kw in node.keywords: + if kw.arg != "requires_ack": + continue + if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, bool): + offenders.append( + f"line {kw.value.lineno}: requires_ack={kw.value.value}" + ) + assert offenders == [], ( + "hand-set requires_ack literals remain in notification_delivery.py: " + f"{offenders}" + ) diff --git a/tests/foundation/test_identity.py b/tests/foundation/test_identity.py new file mode 100644 index 00000000..7438ccf9 --- /dev/null +++ b/tests/foundation/test_identity.py @@ -0,0 +1,220 @@ +"""Tier 1 — identity self-tests. Fast (no DB, no network).""" + +from __future__ import annotations + +from enum import IntEnum + +import pytest +from roboco.foundation import identity +from roboco.seeds.initial_data import AGENT_UUIDS + + +def test_role_enum_has_every_role_inc_system() -> None: + """Every role the system uses must be enumerated, including SYSTEM.""" + expected = { + "developer", + "qa", + "documenter", + "cell_pm", + "main_pm", + "product_owner", + "head_marketing", + "auditor", + "ceo", + "system", + } + actual = {r.value for r in identity.Role} + assert actual == expected, f"Role drift: {actual ^ expected}" + + +def test_team_enum_has_marketing_legacy_and_system() -> None: + """Team enum keeps MARKETING for legacy seed-data parity; SYSTEM for sentinel.""" + expected = { + "backend", + "frontend", + "ux_ui", + "board", + "main_pm", + "fullstack", + "marketing", # legacy — see spec §5.1 + "system", + } + actual = {t.value for t in identity.Team} + assert actual == expected, f"Team drift: {actual ^ expected}" + + +def test_role_level_is_int_enum() -> None: + """RoleLevel is hierarchical (orderable), not a stringly-typed set.""" + assert issubclass(identity.RoleLevel, IntEnum) + # CEO > everyone else + assert identity.RoleLevel.CEO > identity.RoleLevel.AUDITOR + assert identity.RoleLevel.AUDITOR > identity.RoleLevel.MAIN_PM + assert identity.RoleLevel.MAIN_PM > identity.RoleLevel.CELL_PM + assert identity.RoleLevel.CELL_PM > identity.RoleLevel.DOCUMENTER + assert identity.RoleLevel.DOCUMENTER > identity.RoleLevel.QA + assert identity.RoleLevel.QA > identity.RoleLevel.DEV + assert identity.RoleLevel.DEV > identity.RoleLevel.SYSTEM + + +def test_agents_catalog_has_all_seed_slugs() -> None: + """Every slug from seeds/initial_data.AGENT_UUIDS is in foundation.AGENTS.""" + expected_slugs = { + "system", + "ceo", + "be-dev-1", + "be-dev-2", + "be-qa", + "be-pm", + "be-doc", + "fe-dev-1", + "fe-dev-2", + "fe-qa", + "fe-pm", + "fe-doc", + "ux-dev-1", + "ux-dev-2", + "ux-qa", + "ux-pm", + "ux-doc", + "main-pm", + "product-owner", + "head-marketing", + "auditor", + } + actual = set(identity.AGENTS.keys()) + assert actual == expected_slugs, f"agent catalog drift: {actual ^ expected_slugs}" + + +def test_agents_uuids_match_seed() -> None: + """UUIDs match seeds/initial_data.AGENT_UUIDS (the authoritative seed map).""" + for slug, expected_uuid_str in AGENT_UUIDS.items(): + assert str(identity.AGENTS[slug].uuid) == expected_uuid_str, ( + f"UUID drift for {slug!r}: foundation says " + f"{identity.AGENTS[slug].uuid}, seed says {expected_uuid_str}" + ) + + +def test_ceo_is_human() -> None: + """CEO is the only is_human=True row.""" + humans = {slug for slug, row in identity.AGENTS.items() if row.is_human} + assert humans == {"ceo"}, f"unexpected human flag: {humans}" + + +def test_head_marketing_team_is_board() -> None: + """Resolves the head-marketing.team drift (spec §5.1).""" + assert identity.AGENTS["head-marketing"].team == identity.Team.BOARD + + +def test_no_agent_declares_marketing_team() -> None: + """Team.MARKETING exists for legacy parity; no agent should claim it.""" + using_marketing = [ + slug + for slug, row in identity.AGENTS.items() + if row.team == identity.Team.MARKETING + ] + assert using_marketing == [], ( + f"agents claiming Team.MARKETING (legacy): {using_marketing}" + ) + + +def test_pm_roles_is_canonical() -> None: + """PM_ROLES is exactly {CELL_PM, MAIN_PM} — replaces both forked variants.""" + assert ( + frozenset({identity.Role.CELL_PM, identity.Role.MAIN_PM}) == identity.PM_ROLES + ) + + +def test_board_roles_includes_auditor() -> None: + """BOARD_ROLES is the strategic layer (PO + Head Marketing + Auditor).""" + assert ( + frozenset( + { + identity.Role.PRODUCT_OWNER, + identity.Role.HEAD_MARKETING, + identity.Role.AUDITOR, + } + ) + == identity.BOARD_ROLES + ) + + +def test_dev_roles_has_developer_only() -> None: + """DEV_ROLES intentionally narrow — devs only, no QA/Doc.""" + assert frozenset({identity.Role.DEVELOPER}) == identity.DEV_ROLES + + +def test_all_roles_covers_enum() -> None: + """ALL_ROLES matches the Role enum exactly.""" + assert frozenset(identity.Role) == identity.ALL_ROLES + + +def test_role_level_covers_every_role() -> None: + """Every Role has a RoleLevel. SYSTEM is the sentinel (lowest).""" + for role in identity.Role: + assert role in identity.ROLE_LEVEL, f"Role.{role.name} missing from ROLE_LEVEL" + assert identity.ROLE_LEVEL[identity.Role.SYSTEM] == identity.RoleLevel.SYSTEM + assert identity.ROLE_LEVEL[identity.Role.CEO] == identity.RoleLevel.CEO + + +def test_role_level_orders_correctly() -> None: + """CEO > AUDITOR > BOARD > MAIN_PM > CELL_PM > DOC > QA > DEV > SYSTEM.""" + levels = [ + identity.ROLE_LEVEL[r] + for r in ( + identity.Role.CEO, + identity.Role.AUDITOR, + identity.Role.PRODUCT_OWNER, # BOARD level + identity.Role.MAIN_PM, + identity.Role.CELL_PM, + identity.Role.DOCUMENTER, + identity.Role.QA, + identity.Role.DEVELOPER, + identity.Role.SYSTEM, + ) + ] + assert levels == sorted(levels, reverse=True) + + +def test_agent_for_slug_returns_row() -> None: + row = identity.agent_for_slug("be-dev-1") + assert row.slug == "be-dev-1" + assert row.role == identity.Role.DEVELOPER + assert row.team == identity.Team.BACKEND + + +def test_agent_for_slug_unknown_raises_key_error() -> None: + with pytest.raises(KeyError) as exc_info: + identity.agent_for_slug("notreal-1") + assert "notreal-1" in str(exc_info.value) + + +def test_slugs_for_role_developer() -> None: + devs = identity.slugs_for_role(identity.Role.DEVELOPER) + assert devs == frozenset( + {"be-dev-1", "be-dev-2", "fe-dev-1", "fe-dev-2", "ux-dev-1", "ux-dev-2"} + ) + + +def test_slugs_for_role_system_returns_singleton() -> None: + assert identity.slugs_for_role(identity.Role.SYSTEM) == frozenset({"system"}) + + +def test_slugs_for_team_backend() -> None: + backend = identity.slugs_for_team(identity.Team.BACKEND) + assert backend == frozenset({"be-dev-1", "be-dev-2", "be-qa", "be-pm", "be-doc"}) + + +def test_slugs_for_team_marketing_is_empty() -> None: + """Team.MARKETING is legacy — no agent declares it.""" + assert identity.slugs_for_team(identity.Team.MARKETING) == frozenset() + + +def test_role_for_slug() -> None: + assert identity.role_for_slug("be-pm") == identity.Role.CELL_PM + assert identity.role_for_slug("ceo") == identity.Role.CEO + + +def test_team_for_slug() -> None: + assert identity.team_for_slug("be-dev-1") == identity.Team.BACKEND + assert identity.team_for_slug("ceo") == identity.Team.BOARD + assert identity.team_for_slug("head-marketing") == identity.Team.BOARD diff --git a/tests/foundation/test_journaling.py b/tests/foundation/test_journaling.py new file mode 100644 index 00000000..e0d87f4d --- /dev/null +++ b/tests/foundation/test_journaling.py @@ -0,0 +1,84 @@ +"""Tier 1 — journaling scope catalog.""" + +from __future__ import annotations + +from roboco.foundation import identity +from roboco.foundation.policy import journaling +from roboco.models.base import JournalEntryType + + +def test_scope_enum_has_five_panel_ui_values() -> None: + """Panel UI exposes 5 scopes: Notes/Decisions/Reflections/Learnings/Struggles.""" + expected = {"note", "decision", "reflect", "learning", "struggle"} + actual = {s.value for s in journaling.Scope} + assert actual == expected, f"Scope drift: {actual ^ expected}" + + +def test_scope_to_type_covers_every_scope() -> None: + for scope in journaling.Scope: + assert scope in journaling.SCOPE_TO_TYPE, f"{scope.value} missing" + + +def test_scope_to_type_maps_to_canonical_journal_entry_types() -> None: + assert journaling.SCOPE_TO_TYPE[journaling.Scope.NOTE] == JournalEntryType.GENERAL + assert ( + journaling.SCOPE_TO_TYPE[journaling.Scope.DECISION] + == JournalEntryType.DECISION_LOG + ) + assert ( + journaling.SCOPE_TO_TYPE[journaling.Scope.REFLECT] + == JournalEntryType.TASK_REFLECTION + ) + assert ( + journaling.SCOPE_TO_TYPE[journaling.Scope.LEARNING] == JournalEntryType.LEARNING + ) + assert ( + journaling.SCOPE_TO_TYPE[journaling.Scope.STRUGGLE] == JournalEntryType.STRUGGLE + ) + + +def test_scope_string_values_match_panel_ui() -> None: + """The agent-facing string values must match what the UI renders.""" + assert journaling.Scope.NOTE.value == "note" + assert journaling.Scope.DECISION.value == "decision" + assert journaling.Scope.REFLECT.value == "reflect" + assert journaling.Scope.LEARNING.value == "learning" + assert journaling.Scope.STRUGGLE.value == "struggle" + + +def test_read_tier_enum_has_5_levels() -> None: + expected = {"own", "cell", "cell_and_pms", "all_cells", "all"} + actual = {t.value for t in journaling.ReadTier} + assert actual == expected + + +def test_role_read_tiers_cover_every_role() -> None: + for role in identity.Role: + assert role in journaling.ROLE_READ_TIERS, f"Role.{role.name} missing" + + +def test_protected_journals_includes_ceo_and_auditor() -> None: + assert "ceo" in journaling.PROTECTED_JOURNALS + assert "auditor" in journaling.PROTECTED_JOURNALS + + +def test_developer_read_tier_is_cell() -> None: + assert ( + journaling.ROLE_READ_TIERS[identity.Role.DEVELOPER] == journaling.ReadTier.CELL + ) + + +def test_main_pm_read_tier_is_all_cells() -> None: + assert ( + journaling.ROLE_READ_TIERS[identity.Role.MAIN_PM] + == journaling.ReadTier.ALL_CELLS + ) + + +def test_auditor_read_tier_is_all() -> None: + """Auditor sees everything (silent observer).""" + assert journaling.ROLE_READ_TIERS[identity.Role.AUDITOR] == journaling.ReadTier.ALL + + +def test_ceo_read_tier_is_all() -> None: + assert journaling.ROLE_READ_TIERS[identity.Role.CEO] == journaling.ReadTier.ALL diff --git a/tests/foundation/test_journaling_consumers.py b/tests/foundation/test_journaling_consumers.py new file mode 100644 index 00000000..3f4ab5f5 --- /dev/null +++ b/tests/foundation/test_journaling_consumers.py @@ -0,0 +1,63 @@ +"""Verify journaling-scope consumers derive from foundation.""" + +from __future__ import annotations + +from roboco.enforcement import journal_perms +from roboco.enforcement.journal_perms import can_read_journal +from roboco.foundation.policy import journaling +from roboco.services.gateway import content_actions +from roboco.services.journal import _SCOPE_TO_TYPE + + +def test_content_actions_valid_scopes_match_foundation() -> None: + foundation_values = {s.value for s in journaling.Scope} + config_values = set(content_actions._VALID_NOTE_SCOPES) + assert config_values == foundation_values, ( + f"_VALID_NOTE_SCOPES drift: {config_values ^ foundation_values}" + ) + + +def test_journal_service_scope_to_type_matches_foundation() -> None: + # Service uses string keys; foundation uses Scope enum keys. + foundation_str_keys = {s.value: t for s, t in journaling.SCOPE_TO_TYPE.items()} + assert foundation_str_keys == _SCOPE_TO_TYPE, ( + f"_SCOPE_TO_TYPE drift: " + f"foundation={foundation_str_keys}, service={_SCOPE_TO_TYPE}" + ) + + +def test_journal_perms_protected_journals_match_foundation() -> None: + assert journal_perms.PROTECTED_JOURNALS == journaling.PROTECTED_JOURNALS + + +def test_journal_perms_validate_read_behavior_preserved() -> None: + """Functional smoke: existing public surface still routes correctly. + + Pre-Phase-2 behavior the foundation tiers must reproduce: + - CEO can read any journal (protected or not). + - Auditor can read protected journals (e.g. CEO's). + - Main PM can read any non-protected journal but NOT protected ones. + - A developer can read their own journal and same-cell members'. + - A developer cannot read another cell's journals. + """ + # CEO reads anything (including protected auditor journal) + can, _ = can_read_journal("ceo", "auditor") + assert can is True + # Auditor reads protected journals (CEO's) + can, _ = can_read_journal("auditor", "ceo") + assert can is True + # Main PM reads non-protected + can, _ = can_read_journal("main-pm", "be-dev-1") + assert can is True + # Main PM cannot read protected (CEO) + can, _ = can_read_journal("main-pm", "ceo") + assert can is False + # Developer can read own journal + can, _ = can_read_journal("be-dev-1", "be-dev-1") + assert can is True + # Developer can read same-cell QA + can, _ = can_read_journal("be-dev-1", "be-qa") + assert can is True + # Developer cannot read other cell's journals + can, _ = can_read_journal("be-dev-1", "fe-dev-1") + assert can is False diff --git a/tests/foundation/test_lifecycle_consumer_parity.py b/tests/foundation/test_lifecycle_consumer_parity.py new file mode 100644 index 00000000..2f91824a --- /dev/null +++ b/tests/foundation/test_lifecycle_consumer_parity.py @@ -0,0 +1,1653 @@ +"""Tier 2 - choreographer verb output <-> spec.Decision parity. + +For every (role x verb x status x task_type) combo, the choreographer's +envelope must match what spec.can_invoke_intent predicts. This is the +test that makes drift between the spec and the verb body impossible. +""" + +from __future__ import annotations + +from itertools import product +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from roboco.foundation.policy import lifecycle as spec +from roboco.services.gateway.choreographer import ( + Choreographer, + ChoreographerDeps, +) +from roboco.services.gateway.choreographer._impl import DelegateInputs + + +def _make_deps(task_svc=None) -> ChoreographerDeps: + base = { + "task": task_svc or AsyncMock(), + "work_session": AsyncMock(), + "git": AsyncMock(), + "a2a": AsyncMock(), + "journal": AsyncMock(), + "audit": AsyncMock(), + "evidence_repo": AsyncMock(), + } + repo = base["evidence_repo"] + 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 ChoreographerDeps(**base) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status, task_type", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ["code"], + ) + ), +) +async def test_i_will_work_on_matches_spec( + role: str, status: str, task_type: str +) -> None: + """Every (role, status, task_type) combo: envelope.error matches spec.Decision. + + Tasks in claimed/in_progress are kept assigned to a DIFFERENT agent so + the verb's idempotent re-entry / claimed-recovery paths do not bypass + the spec gate. Those re-entry paths are behavioral concerns the spec + does not model and are pinned by separate unit tests. + """ + agent_id = uuid4() + task_id = uuid4() + other_agent_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type=task_type, + # Always assign to a different agent so the idempotent / recovery + # branches in the verb body (which intentionally bypass the spec + # gate) do not fire. + assigned_to=other_agent_id if status in ("claimed", "in_progress") else None, + plan="some plan" if status in ("in_progress",) else None, + commits=[], + pr_number=None, + branch_name="feature/x", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] + task_svc.claim.return_value = MagicMock( + id=task_id, status="claimed", assigned_to=agent_id, plan=None + ) + task_svc.set_plan.return_value = MagicMock( + id=task_id, status="claimed", assigned_to=agent_id, plan="my plan" + ) + task_svc.start.return_value = MagicMock( + id=task_id, + status="in_progress", + assigned_to=agent_id, + plan="my plan", + task_type=task_type, + ) + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + ctx = spec.Context(plan="my plan", actor_id=agent_id) + expected = spec.can_invoke_intent(spec.Role(role), "i_will_work_on", task, ctx) + # Per-role claim authority (CLAIM_RULES) is now enforced inside + # spec.can_invoke_action when action == "claim", dispatched by + # can_invoke_intent. The verb's single gate is can_invoke_intent. + env = await c.i_will_work_on(agent_id, task_id, plan="my plan") + body = env.as_dict() + if expected.allowed: + # Verb may still fail downstream of the gate (e.g. claim() returns + # None, runner exception); but the spec gate should not reject. + assert body["error"] != "not_authorized", ( + f"role={role} status={status} task_type={task_type}: " + f"spec.can_invoke_intent allowed but envelope returned " + f"not_authorized: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status} task_type={task_type}: " + f"can_invoke_intent rejected with {expected.rejection_kind}, " + f"got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status, task_type", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ["planning", "code"], + ) + ), +) +async def test_i_will_plan_matches_spec(role: str, status: str, task_type: str) -> None: + """Every (role, status, task_type) combo: envelope.error matches spec.Decision. + + Mirror of ``test_i_will_work_on_matches_spec`` for the PM planning verb. + Tasks in claimed/in_progress are kept assigned to a DIFFERENT agent so + the verb's idempotent re-entry / claimed-recovery paths do not bypass + the spec gate. Those re-entry paths are behavioral concerns the spec + does not model and are pinned by separate unit tests. + """ + agent_id = uuid4() + task_id = uuid4() + other_agent_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type=task_type, + # Always assign to a different agent so the idempotent / recovery + # branches in the verb body (which intentionally bypass the spec + # gate) do not fire. + assigned_to=other_agent_id if status in ("claimed", "in_progress") else None, + plan="some plan" if status in ("in_progress",) else None, + commits=[], + pr_number=None, + branch_name="feature/x", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] + task_svc.claim.return_value = MagicMock( + id=task_id, status="claimed", assigned_to=agent_id, plan=None + ) + task_svc.set_plan.return_value = MagicMock( + id=task_id, status="claimed", assigned_to=agent_id, plan="my plan" + ) + task_svc.start.return_value = MagicMock( + id=task_id, + status="in_progress", + assigned_to=agent_id, + plan="my plan", + task_type=task_type, + ) + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + ctx = spec.Context(plan="my plan", actor_id=agent_id) + expected = spec.can_invoke_intent(spec.Role(role), "i_will_plan", task, ctx) + # Per-role claim authority (CLAIM_RULES) is now enforced inside + # spec.can_invoke_action when action == "claim", dispatched by + # can_invoke_intent. The verb's single gate is can_invoke_intent. + env = await c.i_will_plan(agent_id, task_id, plan="my plan") + body = env.as_dict() + if expected.allowed: + # Verb may still fail downstream of the gate (e.g. claim() returns + # None, runner exception); but the spec gate should not reject. + assert body["error"] != "not_authorized", ( + f"role={role} status={status} task_type={task_type}: " + f"spec.can_invoke_intent allowed but envelope returned " + f"not_authorized: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status} task_type={task_type}: " + f"can_invoke_intent rejected with {expected.rejection_kind}, " + f"got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_delegate_matches_spec(role: str, status: str) -> None: + """Spec parity for delegate's role+state gate. + + delegate composes ``create_subtask`` (PM-only, parent must be + in_progress). The verb body has additional gates the spec doesn't + model (delegation chain, assignee-vs-task_type, parent-ownership, + subtask cap), so this parity test only asserts the spec's role+state + rejection is correctly mirrored. Chain/assignee/lifecycle-guard + rejections are pinned by separate unit tests in + test_choreographer_pm_extras / test_choreographer_delegate_guards. + + Inputs use a valid main_pm -> be-pm planning chain so when the spec + gate passes, downstream chain/assignee guards pass for main_pm; for + other roles the spec gate is what rejects. + """ + pm_id = uuid4() + parent_id = uuid4() + project_id = uuid4() + parent = MagicMock( + id=parent_id, + project_id=project_id, + status=status, + # Parent owned by the caller so _delegate_lifecycle_guards's + # ownership check passes when the spec gate allows. + assigned_to=pm_id, + title="parent", + team="backend", + ) + new_task = MagicMock(id=uuid4()) + task_svc = AsyncMock() + task_svc.get.return_value = parent + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role=role, team="backend", slug=None + ) + task_svc.get_subtasks.return_value = [] + task_svc.create_subtask.return_value = new_task + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=pm_id) + expected = spec.can_invoke_intent(spec.Role(role), "delegate", parent, ctx) + + env = await c.delegate( + pm_id, + parent_id, + DelegateInputs( + title="Backend planning", + description="Plan backend slice for feature X", + assigned_to="be-pm", + team="backend", + task_type="planning", + ), + ) + body = env.as_dict() + if expected.allowed: + # Spec allows; chain (main_pm -> be-pm planning) is also valid for + # role=main_pm. For other PM roles, downstream chain/assignee guards + # may still reject — but never with the spec's role-only message. + spec_role_msg = f"role '{role}' may not call 'delegate'" + assert body.get("message") != spec_role_msg, ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced the spec's role-rejection message: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status, commits_count, has_pr, owned", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + [0, 1], + [False, True], + [False, True], + ) + ), +) +async def test_open_pr_matches_spec( + role: str, status: str, commits_count: int, has_pr: bool, owned: bool +) -> None: + """Spec parity for open_pr's role + extra-preconditions gate. + + open_pr's IntentSpec has ``composes=()`` — it's a side-effect-only + verb (push_branch + create_pr). The spec gate enforces: + + - role in _DEV_ROLES (DEVELOPER only), + - PRECONDITION_OWNERSHIP (task.assigned_to == ctx.actor_id), + - PRECONDITION_COMMITS (>=1 commit), + - PRECONDITION_NO_PR (pr_number is None). + + The verb's idempotent re-entry path (owner + pr_number set returns OK) + intentionally bypasses the spec gate, since the spec would otherwise + reject with `tracing_gap` on `no_prior_pr`. That branch is pinned by + test_choreographer_dev / test_open_pr unit tests; here we exercise + the non-idempotent combos so the spec gate is the load-bearing check. + """ + agent_id = uuid4() + task_id = uuid4() + other_agent_id = uuid4() + assigned_to = agent_id if owned else other_agent_id + # Skip the verb's idempotent shortcut: owner-with-PR returns OK + # without invoking the spec gate. That's a behavioral concession the + # spec doesn't model, pinned by separate unit tests. + if owned and has_pr: + pytest.skip("idempotent re-entry path bypasses spec gate by design") + task = MagicMock( + id=task_id, + status=status, + task_type="code", + assigned_to=assigned_to, + commits=[{"sha": f"abc{i}"} for i in range(commits_count)], + pr_number=7 if has_pr else None, + pr_url="https://gh/x/7" if has_pr else None, + branch_name="feature/backend/abc12345", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + git_svc = AsyncMock() + git_svc.push_branch.return_value = ("feature/backend/abc12345", 1) + git_svc.create_pr.return_value = { + "pr_number": 42, + "pr_url": "https://gh/x/42", + "is_root_pr": False, + } + deps = _make_deps(task_svc=task_svc) + deps = ChoreographerDeps( + task=task_svc, + work_session=deps.work_session, + git=git_svc, + a2a=deps.a2a, + journal=deps.journal, + audit=deps.audit, + evidence_repo=deps.evidence_repo, + ) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id) + expected = spec.can_invoke_intent(spec.Role(role), "open_pr", task, ctx) + + env = await c.open_pr(agent_id, task_id) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope (OK) + # OR a downstream invalid_state if the runner hits an exception + # we didn't fully wire in this test mock. The spec gate itself + # must NOT be the source of any not_authorized / tracing_gap. + assert body["error"] not in ("not_authorized", "tracing_gap"), ( + f"role={role} status={status} commits={commits_count} " + f"has_pr={has_pr} owned={owned}: spec.can_invoke_intent " + f"allowed but envelope rejected at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status} commits={commits_count} " + f"has_pr={has_pr} owned={owned}: can_invoke_intent rejected " + f"with {expected.rejection_kind}, got {body['error']!r}; " + f"full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status, commits_count, owned", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + [0, 1], + [False, True], + ) + ), +) +async def test_i_am_done_matches_spec( + role: str, status: str, commits_count: int, owned: bool +) -> None: + """Spec parity for i_am_done's role + extra-preconditions gate. + + i_am_done's IntentSpec composes ``(submit_verification, submit_qa)`` + with ``extra_preconditions=(PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS)``. + The spec gate enforces: + + - role in _DEV_ROLES (DEVELOPER only), + - PRECONDITION_OWNERSHIP (task.assigned_to == ctx.actor_id), + - PRECONDITION_COMMITS (>=1 commit), + - first composed action submit_verification's source_status (IN_PROGRESS). + + The verb's recovery branch (owner + status==verifying runs submit_qa + directly, bypassing the spec gate) intentionally short-circuits — the + spec doesn't model partial-progress recovery. We skip that single combo + so the parity check is honest about what the spec gate evaluates. + """ + agent_id = uuid4() + task_id = uuid4() + other_agent_id = uuid4() + assigned_to = agent_id if owned else other_agent_id + # Skip the verb's recovery shortcut: owner-with-status==verifying runs + # submit_qa directly without invoking the spec gate. Pinned by separate + # unit tests; here we exercise the non-recovery combos so the spec gate + # is the load-bearing check. + if owned and status == "verifying": + pytest.skip("recovery re-entry path bypasses spec gate by design") + task = MagicMock( + id=task_id, + status=status, + task_type="code", + assigned_to=assigned_to, + commits=[{"sha": f"abc{i}"} for i in range(commits_count)], + pr_number=7, + pr_url="https://gh/x/7", + branch_name="feature/backend/abc12345", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + # Tracing-gate fields satisfied so a downstream tracing_gap doesn't + # mask the spec-layer outcome on the allowed branch. + plan={"x": 1}, + progress_updates=[{"message": "p"}], + acceptance_criteria=[], + acceptance_criteria_status=[], + documents=[], + dev_notes="", + work_session_id=None, + self_verified=False, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.submit_verification.return_value = MagicMock( + id=task_id, status="verifying", assigned_to=agent_id + ) + task_svc.submit_qa.return_value = MagicMock( + id=task_id, + status="awaiting_qa", + assigned_to=None, + team="backend", + pr_url="https://gh/x/7", + work_session_id=None, + ) + task_svc.qa_agent_for_team.return_value = None + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + deps = _make_deps(task_svc=task_svc) + journal_svc = deps.journal + journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False + work_svc = deps.work_session + work_svc.files_changed.return_value = [] + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id, notes="done") + expected = spec.can_invoke_intent(spec.Role(role), "i_am_done", task, ctx) + + env = await c.i_am_done(agent_id, task_id, "done") + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope (OK) + # OR a downstream tracing_gap from defense-in-depth gates (PR/commits/ + # progress) — but the spec gate itself must NOT be the source of any + # not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status} commits={commits_count} " + f"owned={owned}: spec.can_invoke_intent allowed but envelope " + f"surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status} commits={commits_count} " + f"owned={owned}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_i_am_blocked_matches_spec(role: str, status: str) -> None: + """Spec parity for i_am_blocked's role + state gate. + + i_am_blocked's IntentSpec composes ``(block,)`` with no + ``extra_preconditions``. The spec gate enforces: + + - role in (_DEV_ROLES | _QA_ROLES | _DOC_ROLES), + - composed ``block`` action's source_status (IN_PROGRESS only). + + The verb body has no idempotent / recovery short-circuits, so every + combo flows through the spec gate. The journal:struggle write is a + side effect outside the lifecycle action and lives in the verb body + after the spec gate accepts; it does not affect parity outcomes. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Ownership doesn't gate i_am_blocked (no PRECONDITION_OWNERSHIP), + # but assigning the task to the caller keeps the downstream + # task_service.escalate mock realistic. + assigned_to=agent_id, + commits=[], + pr_number=None, + branch_name="feature/x", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + pre_block_state=None, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.escalate.return_value = MagicMock( + id=task_id, status="blocked", assigned_to=agent_id + ) + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id, notes="external API down") + expected = spec.can_invoke_intent(spec.Role(role), "i_am_blocked", task, ctx) + + env = await c.i_am_blocked(agent_id, task_id, "external API down") + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope (OK) + # or a downstream invalid_state if the runner hits an exception + # we didn't fully wire in this test mock. The spec gate itself + # must NOT be the source of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_unclaim_matches_spec(role: str, status: str) -> None: + """Spec parity for unclaim's role gate. + + unclaim's IntentSpec has ``composes=()`` — no atomic action runs, so + the spec gate enforces only role membership (no source-status + constraint). The verb body owns dispatch via + ``task.unclaim_for_agent``; the service-level guard refuses with + None when the status isn't claimed/in_progress, surfacing as + invalid_state from the verb body. That service-layer rejection is + NOT the spec's concern. + + Tasks are kept ``assigned_to=agent_id`` so the verb's + reassignment-rejection branch (Task 6 fix in commit a5d358d) does not + fire — that branch is a Choreographer-level guard the spec doesn't + model and is pinned by separate unit tests in test_unclaim.py. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Owned by caller so the reassignment-rejection branch does not + # fire; we want the spec gate to be the only rejector here. + assigned_to=agent_id, + commits=[], + pr_number=None, + branch_name="feature/x", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + # Service-level guard: returns the post-unclaim task on success, or + # None on state drift. For the parity test we always return a stub + # so the verb body's None-branch (invalid_state) doesn't mask the + # spec-layer outcome on the allowed branch. + task_svc.unclaim_for_agent.return_value = MagicMock( + id=task_id, status="pending", assigned_to=None + ) + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id) + expected = spec.can_invoke_intent(spec.Role(role), "unclaim", task, ctx) + + env = await c.unclaim(agent_id, task_id) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope (OK) + # or a downstream invalid_state if unclaim_for_agent returns + # None — but the spec gate itself must NOT be the source of + # any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_resume_matches_spec(role: str, status: str) -> None: + """Spec parity for resume's role + state gate. + + resume's IntentSpec composes ``("resume",)``. The spec gate enforces: + + - role in (_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + - composed ``resume`` action's source_status (PAUSED only). + + Tasks are kept ``assigned_to=agent_id`` so the verb's + reassignment-rejection branch (Task 6 fix in commit a5d358d) does not + fire — that branch is a Choreographer-level guard the spec doesn't + model and is pinned by separate unit tests in test_resume.py. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Owned by caller so the reassignment-rejection branch does not + # fire; we want the spec gate to be the only rejector here. + assigned_to=agent_id, + commits=[], + pr_number=None, + branch_name="feature/x", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.resume_for_agent.return_value = MagicMock( + id=task_id, status="in_progress", assigned_to=agent_id + ) + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id) + expected = spec.can_invoke_intent(spec.Role(role), "resume", task, ctx) + + env = await c.resume(agent_id, task_id) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope (OK) + # or a downstream invalid_state if the runner hits an exception + # we didn't fully wire in this test mock. The spec gate itself + # must NOT be the source of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_complete_matches_spec(role: str, status: str) -> None: + """Spec parity for complete's role + state gate (the dispatcher layer). + + complete's IntentSpec composes ``("complete",)``; the spec gate + enforces: + + - role in _PM_ROLES (CELL_PM, MAIN_PM), + - composed ``complete`` action's source_status (AWAITING_PM_REVIEW only). + + The dispatcher routes to ``cell_pm_complete`` / ``main_pm_complete`` + after the gate accepts. Both lower-level methods retain their own + pre-flight guards (PR mergeability, journal:decision presence, + subtasks-terminal) — those model preconditions the spec doesn't + cover, and may emit non-spec rejection kinds (tracing_gap, + invalid_state). The parity assertion is therefore one-sided: when + the spec rejects, the envelope MUST surface that exact + rejection_kind; when the spec allows, the envelope may still be + rejected by a downstream guard, but the spec gate itself must NOT + be the source of any not_authorized rejection. + + Tasks are kept ``assigned_to=agent_id`` so the lower-level guards' + "not assigned to you" branch does not fire on the allowed path. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Owned by caller so the lower-level _*_pm_complete_guard + # "not assigned to you" branch does not fire on the allowed + # path; we want the spec gate to be the only rejector here. + assigned_to=agent_id, + commits=[], + pr_number=8, + branch_name="feature/backend/abc--def", + # Cell-PM path needs a parent_task_id; main-PM path needs None. + # Pick parent_task_id=None so main_pm_complete's own non-root + # guard doesn't fire on cell_pm. cell_pm_complete doesn't + # require parent_task_id either — _maybe_advance_parent_to_pm_review + # short-circuits when leaf_parent_id is None. + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + after = MagicMock( + id=task_id, status="completed", assigned_to=agent_id, team="backend" + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.cell_pm_complete.return_value = after + task_svc.escalate_to_ceo.return_value = MagicMock( + id=task_id, status="awaiting_ceo_approval", assigned_to=None, team="backend" + ) + task_svc.all_subtasks_terminal.return_value = True + git_svc = AsyncMock() + git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "x"} + git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "x"} + git_svc.pr_target.return_value = "master" + journal_svc = AsyncMock() + journal_svc.has_decision_for_task.return_value = True + deps_kwargs = { + "task": task_svc, + "work_session": AsyncMock(), + "git": git_svc, + "a2a": AsyncMock(), + "journal": journal_svc, + "audit": AsyncMock(), + "evidence_repo": AsyncMock(), + } + repo = deps_kwargs["evidence_repo"] + 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 = [] + deps = ChoreographerDeps(**deps_kwargs) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id) + expected = spec.can_invoke_intent(spec.Role(role), "complete", task, ctx) + + env = await c.complete(agent_id, task_id, notes="reviewed and approved") + body = env.as_dict() + if expected.allowed: + # Spec allows. The dispatcher routes to cell_pm_complete or + # main_pm_complete; downstream guards may still reject (e.g. + # tracing_gap on missing journal:decision), but the spec gate + # itself must NOT be the source of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_escalate_up_matches_spec(role: str, status: str) -> None: + """Spec parity for escalate_up's role gate. + + escalate_up's IntentSpec has ``composes=()`` — no atomic action runs, + so the spec gate enforces only role membership (cell_pm or main_pm), + no source-status constraint. The verb body owns dispatch via + ``task.escalate(...)`` and keeps two verb-specific preflight guards + the spec does not model: ``journal:decision`` presence, and + ``escalation_target`` configuration on the agent record. Both are + satisfied here so the spec gate is the load-bearing rejector. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + assigned_to=agent_id, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + after = MagicMock( + id=task_id, status="blocked", assigned_to=agent_id, team="backend" + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + # escalation_target is not on the spec — set it so the verb-specific + # preflight does not surface a non-spec invalid_state on the allowed + # branch and mask the spec-layer outcome. + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None, escalation_target="main-pm" + ) + task_svc.escalate.return_value = after + journal_svc = AsyncMock() + # journal:decision is not on the spec — satisfy it so the verb-specific + # preflight does not surface a non-spec tracing_gap on the allowed branch. + journal_svc.has_decision_for_task.return_value = True + deps = _make_deps(task_svc=task_svc) + deps = ChoreographerDeps( + task=task_svc, + work_session=deps.work_session, + git=deps.git, + a2a=deps.a2a, + journal=journal_svc, + audit=deps.audit, + evidence_repo=deps.evidence_repo, + ) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id, notes="please help") + expected = spec.can_invoke_intent(spec.Role(role), "escalate_up", task, ctx) + + env = await c.escalate_up(agent_id, task_id, reason="please help") + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb-specific preflight (journal:decision + + # escalation_target) is wired to pass; the verb may still surface + # OK or a downstream invalid_state if task.escalate returns None, + # but the spec gate itself must NOT be the source of any + # not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_escalate_to_ceo_matches_spec(role: str, status: str) -> None: + """Spec parity for escalate_to_ceo's role + state gate. + + escalate_to_ceo's IntentSpec composes ``("escalate_to_ceo",)``. The + spec gate enforces: + + - role in {main_pm, product_owner, head_marketing}, + - composed ``escalate_to_ceo`` action's source_status + (AWAITING_PM_REVIEW only). + + The verb body keeps the journal:decision preflight (the spec doesn't + model journal side effects); satisfied here so the spec gate is the + load-bearing rejector. After the runner returns the verb body + reassigns to None (CEO acts via UI). + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Ownership doesn't gate escalate_to_ceo (no PRECONDITION_OWNERSHIP). + assigned_to=agent_id, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + after = MagicMock( + id=task_id, + status="awaiting_ceo_approval", + assigned_to=None, + team="backend", + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.escalate_to_ceo.return_value = after + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + journal_svc = AsyncMock() + journal_svc.has_decision_for_task.return_value = True + deps = _make_deps(task_svc=task_svc) + deps = ChoreographerDeps( + task=task_svc, + work_session=deps.work_session, + git=deps.git, + a2a=deps.a2a, + journal=journal_svc, + audit=deps.audit, + evidence_repo=deps.evidence_repo, + ) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id, notes="ready for CEO sign-off") + expected = spec.can_invoke_intent(spec.Role(role), "escalate_to_ceo", task, ctx) + + env = await c.escalate_to_ceo(agent_id, task_id, reason="ready for CEO sign-off") + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope (OK) + # or a downstream tracing_gap from journal:decision absence (we + # wire it to pass); the spec gate itself must NOT be the source + # of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_submit_up_matches_spec(role: str, status: str) -> None: + """Spec parity for submit_up's role + state gate. + + submit_up's IntentSpec composes ``("submit_pm_review",)`` with side + effects ``("create_pr",)``. The spec gate enforces: + + - role == cell_pm, + - composed ``submit_pm_review`` action's source_status + (IN_PROGRESS only). + + The verb body keeps ``_submit_up_guard`` (ownership + notes-length + + journal:decision + subtasks-terminal + branch-present). All of those + are satisfied here so the spec gate is the load-bearing rejector. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Owned by caller so the verb-specific ownership preflight does + # not surface a non-spec rejection on the allowed branch. + assigned_to=agent_id, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ) + after = MagicMock( + id=task_id, + status="awaiting_pm_review", + assigned_to=agent_id, + branch_name="feature/backend/abc", + team="backend", + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.submit_pm_review.return_value = after + task_svc.all_subtasks_terminal.return_value = True + task_svc.main_pm_agent.return_value = MagicMock(id=uuid4()) + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + git_svc = AsyncMock() + git_svc.create_pr.return_value = {"pr_number": 12, "pr_url": "x"} + journal_svc = AsyncMock() + journal_svc.has_decision_for_task.return_value = True + deps = _make_deps(task_svc=task_svc) + deps = ChoreographerDeps( + task=task_svc, + work_session=deps.work_session, + git=git_svc, + a2a=deps.a2a, + journal=journal_svc, + audit=deps.audit, + evidence_repo=deps.evidence_repo, + ) + c = Choreographer(deps) + + notes = "cell completed all subtasks; ready for main pm review" + ctx = spec.Context(actor_id=agent_id, notes=notes) + expected = spec.can_invoke_intent(spec.Role(role), "submit_up", task, ctx) + + env = await c.submit_up(agent_id, task_id, notes=notes) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope (OK) + # or a downstream tracing_gap from one of the _submit_up_guard + # preconditions (which we wire to pass); the spec gate itself + # must NOT be the source of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_claim_review_matches_spec(role: str, status: str) -> None: + """Spec parity for claim_review's role + claim source-status gate. + + claim_review's IntentSpec composes ``("claim", "start")`` and is + restricted to QA. The spec gate enforces: + + - role == qa, + - claim's source_statuses (PENDING / NEEDS_REVISION / AWAITING_QA + / AWAITING_DOCUMENTATION), + - CLAIM_RULES narrowing (qa only allowed from PENDING / AWAITING_QA). + + The verb body owns dispatch via ``task.qa_claim`` (not the runner's + claim+start chain) because the runtime semantic is "QA inspects, + status stays at awaiting_qa" — see qa.py module docstring. The + behavioral claim guards (already_active / paused / sibling_sequence + skipped) run after the spec gate; they're not modelled by the spec. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + assigned_to=None, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + work_session_id=None, + ) + after = MagicMock( + id=task_id, + status=status, + assigned_to=agent_id, + team="backend", + branch_name="feature/backend/abc", + work_session_id=None, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.qa_claim.return_value = after + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id) + expected = spec.can_invoke_intent(spec.Role(role), "claim_review", task, ctx) + + env = await c.claim_review(agent_id, task_id) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope (OK) + # or a downstream behavioral guard rejection; the spec gate itself + # must NOT be the source of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_pass_review_matches_spec(role: str, status: str) -> None: + """Spec parity for pass_review's role + state gate. + + pass_review's IntentSpec composes ``("qa_pass",)`` and is QA-only. + The spec gate enforces: + + - role == qa, + - composed ``qa_pass`` action's source_status (AWAITING_QA), + - self-review block (qa_pass.self_review_block=True; the verb body + builds a Context with actor_slug + original_developer_slug so the + spec naturally rejects self-review). + + The verb-specific gates (notes-length / journal:learning / + qa_evidence_inspected) live in the verb body — none are modelled by + the spec. They're wired to pass here so the spec gate is the load- + bearing rejector. ``_verify_qa_owner`` runs FIRST (before the spec + gate), so the parity check uses ``assigned_to=agent_id`` to keep + that pre-spec ownership check passing. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Owned by caller so _verify_qa_owner does not surface a non-spec + # not_authorized before the spec gate runs. + assigned_to=agent_id, + qa_evidence_inspected=True, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + pr_url="https://x/pr/8", + work_session_id=None, + ) + after = MagicMock( + id=task_id, + status="awaiting_documentation", + assigned_to=agent_id, + team="backend", + pr_url="https://x/pr/8", + qa_evidence_inspected=True, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.qa_pass.return_value = after + task_svc.documenter_for_team.return_value = None + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + deps = _make_deps(task_svc=task_svc) + journal_svc = deps.journal + journal_svc.has_learning_for_task.return_value = True + c = Choreographer(deps) + + notes = ( + "Reviewed PR carefully. Branch convention correct. Commit prefix " + "verified. README diff matches spec. All acceptance criteria met." + ) + ctx = spec.Context(actor_id=agent_id, notes=notes) + expected = spec.can_invoke_intent(spec.Role(role), "pass_review", task, ctx) + + env = await c.pass_review(agent_id, task_id, notes=notes) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb-specific notes-length / journal:learning / + # qa_evidence_inspected are wired to pass; the spec gate itself + # must NOT be the source of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_fail_review_matches_spec(role: str, status: str) -> None: + """Spec parity for fail_review's role + state gate. + + fail_review's IntentSpec composes ``("qa_fail",)`` and is QA-only. + The spec gate enforces: + + - role == qa, + - composed ``qa_fail`` action's source_status (AWAITING_QA), + - self-review block (qa_fail.self_review_block=True). + + Same shape as pass_review: ownership precedes the spec gate, and + the verb-specific notes-length / journal:learning / + qa_evidence_inspected gates live in the verb body. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Owned by caller so _verify_qa_owner does not surface a non-spec + # not_authorized before the spec gate runs. + assigned_to=agent_id, + qa_evidence_inspected=True, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + work_session_id=None, + ) + dev_id = uuid4() + after = MagicMock( + id=task_id, + status="needs_revision", + assigned_to=dev_id, + team="backend", + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.qa_fail.return_value = after + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + deps = _make_deps(task_svc=task_svc) + journal_svc = deps.journal + journal_svc.has_learning_for_task.return_value = True + c = Choreographer(deps) + + issues = [ + "Missing unit test coverage for /healthz endpoint — add at least one", + "Lint errors in /api/foo.py: unused import and missing return type", + ] + notes = "Issues:\n" + "\n".join(f"- {i}" for i in issues) + ctx = spec.Context(actor_id=agent_id, notes=notes, issues=tuple(issues)) + expected = spec.can_invoke_intent(spec.Role(role), "fail_review", task, ctx) + + env = await c.fail_review(agent_id, task_id, issues=issues) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb-specific gates wired to pass; the spec gate + # itself must NOT be the source of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_claim_doc_task_matches_spec(role: str, status: str) -> None: + """Spec parity for claim_doc_task's role + claim source-status gate. + + claim_doc_task's IntentSpec composes ``("claim", "start")`` and is + restricted to documenter. The spec gate enforces: + + - role == documenter, + - claim's source_statuses, + - CLAIM_RULES narrowing (documenter only from PENDING / + AWAITING_DOCUMENTATION). + + The verb body owns dispatch via ``task.doc_claim`` (not the runner's + claim+start chain) because the runtime semantic is "documenter + inspects, status stays at awaiting_documentation" — see doc.py + module docstring. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + assigned_to=None, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + work_session_id=None, + ) + after = MagicMock( + id=task_id, + status=status, + assigned_to=agent_id, + team="backend", + branch_name="feature/backend/abc", + work_session_id=None, + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.doc_claim.return_value = after + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + ctx = spec.Context(actor_id=agent_id) + expected = spec.can_invoke_intent(spec.Role(role), "claim_doc_task", task, ctx) + + env = await c.claim_doc_task(agent_id, task_id) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb may still surface a non-error envelope or a + # downstream behavioral guard rejection; the spec gate itself + # must NOT be the source of any not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role, status", + list( + product( + [r.value for r in spec.Role if r != spec.Role.AUDITOR], + [s.value for s in spec.Status], + ) + ), +) +async def test_i_documented_matches_spec(role: str, status: str) -> None: + """Spec parity for i_documented's role + state gate. + + i_documented's IntentSpec composes ``("docs_complete",)`` and is + documenter-only. The spec gate enforces: + + - role == documenter, + - composed ``docs_complete`` action's source_status + (AWAITING_DOCUMENTATION), + - self-review block (docs_complete.self_review_block=True; the + verb body builds a Context with actor_slug + + original_developer_slug so the spec naturally rejects + self-review). + + The verb-specific gates (notes-length / files-list) live in the + verb body — not modelled by the spec. They're wired to pass here so + the spec gate is the load-bearing rejector. ``_verify_doc_owner`` + runs FIRST (before the spec gate), so the parity check uses + ``assigned_to=agent_id`` to keep that pre-spec ownership check + passing. + """ + agent_id = uuid4() + task_id = uuid4() + task = MagicMock( + id=task_id, + status=status, + task_type="code", + # Owned by caller so _verify_doc_owner does not surface a non-spec + # not_authorized before the spec gate runs. + assigned_to=agent_id, + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + documents=[], + ) + after = MagicMock( + id=task_id, + status="awaiting_pm_review", + assigned_to=agent_id, + team="backend", + ) + task_svc = AsyncMock() + task_svc.get.return_value = task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.docs_complete.return_value = after + task_svc.cell_pm_for_team.return_value = None + task_svc.session = MagicMock() + task_svc.session.flush = AsyncMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + deps = _make_deps(task_svc=task_svc) + c = Choreographer(deps) + + notes = "Wrote backend/guides/feature-x.md with usage examples + config notes." + files = ["backend/guides/feature-x.md"] + ctx = spec.Context(actor_id=agent_id, notes=notes, files=tuple(files)) + expected = spec.can_invoke_intent(spec.Role(role), "i_documented", task, ctx) + + env = await c.i_documented(agent_id, task_id, notes=notes, files=files) + body = env.as_dict() + if expected.allowed: + # Spec allows. Verb-specific notes-length / files-list are wired + # to pass; the spec gate itself must NOT be the source of any + # not_authorized rejection. + assert body["error"] != "not_authorized", ( + f"role={role} status={status}: spec.can_invoke_intent allowed " + f"but envelope surfaced not_authorized at the spec layer: {body}" + ) + else: + assert body["error"] == expected.rejection_kind, ( + f"role={role} status={status}: can_invoke_intent rejected with " + f"{expected.rejection_kind}, got {body['error']!r}; full body: {body}" + ) diff --git a/tests/foundation/test_lifecycle_generators.py b/tests/foundation/test_lifecycle_generators.py new file mode 100644 index 00000000..d01d3291 --- /dev/null +++ b/tests/foundation/test_lifecycle_generators.py @@ -0,0 +1,49 @@ +"""Generators must produce deterministic, predictable output.""" + +from __future__ import annotations + +import json + +from roboco.foundation import _generators + + +def test_render_intent_verbs_md_lists_every_intent() -> None: + md = _generators.render_intent_verbs_md() + assert "## i_will_work_on" in md + assert "## open_pr" in md + assert "## delegate" in md + + +def test_render_intent_verbs_md_includes_composes_for_each() -> None: + md = _generators.render_intent_verbs_md() + # i_will_work_on composes claim → set_plan → start; expect those names appear. + assert "claim" in md + assert "set_plan" in md + assert "start" in md + + +def test_render_status_transitions_md_has_table_header() -> None: + md = _generators.render_status_transitions_md() + assert "| Source | Target | Action | Roles |" in md + + +def test_render_panel_json_emits_intents_array() -> None: + payload = _generators.render_panel_json() + parsed = json.loads(payload) + assert "intents" in parsed + assert isinstance(parsed["intents"], list) + assert any(i["name"] == "i_will_work_on" for i in parsed["intents"]) + + +def test_render_agent_prompt_fragment_for_developer_lists_dev_verbs() -> None: + fragment = _generators.render_agent_prompt_fragment("developer") + assert "i_will_work_on" in fragment + assert "open_pr" in fragment + assert "delegate" not in fragment # PM only + + +def test_generators_are_deterministic() -> None: + """Two consecutive renders produce the same bytes.""" + a = _generators.render_intent_verbs_md() + b = _generators.render_intent_verbs_md() + assert a == b diff --git a/tests/foundation/test_lifecycle_smoke_replay.py b/tests/foundation/test_lifecycle_smoke_replay.py new file mode 100644 index 00000000..9416ba49 --- /dev/null +++ b/tests/foundation/test_lifecycle_smoke_replay.py @@ -0,0 +1,366 @@ +"""Tier 4 - smoke replay. Pin the 9+2 known-bug shapes from the +2026-05-08 / 2026-05-09 traces. + +Each record in `tests/fixtures/2026-05-08-smoke-trace.json` documents +one bug observed in the audit-log trace and pins the post-fix shape: +verb, role, task setup, expected envelope error (often None for +fixed allow-paths, sometimes a specific rejection_kind for fixed +rejection-with-clear-message paths). If a future spec change +re-introduces one of the 11 bugs, the corresponding parametrized +case here fails with a pointer to the bug id. + +Replay-kind taxonomy (drives which assertions a record gets): + * spec_decision - call spec.can_invoke_intent(...) on a + stub task with the fixture's setup; + assert Decision matches the expected + shape. + * spec_decision_role_only - same, but the spec gate is role-only + (composes=()) so we assert the role + gate allows; verb-body guards are + tested elsewhere. + * spec_decision_then_verb_body - spec gate must allow the verb + body to RUN so it can surface a + verb-specific invalid_state. We + assert the spec gate allows; the + verb-body fix is documented but not + re-asserted here (covered by + test_choreographer_pm_extras). + * spec_introspection - assert structural properties of + spec._INTENT_VERBS or + spec.intents_for_role(...). + * schema_only_skip - bug enforced at HTTP/Pydantic layer; + no spec-level Decision to assert. + Skipped with a documented reason. + * audit_only_skip - audit-layer fix below the spec. + Skipped with a documented reason. + * behavioral_skip - Choreographer-level behavioral + concern (e.g. idempotent re-entry) + the spec intentionally does NOT + model. Skipped with a documented + reason. + +The fixture itself is a SYNTHESIS of the bug list documented in +prior commit messages (504b553, 1e4c7a8, dfbcb3e, a5d358d, 7a2d4e3, +e9d53fb, 5a10ae9, 81ffc16, e51ba30) - the original /tmp/audit-trace.txt +on the NAS was wiped during cleanup before this session. The +synthesis is faithful to the analysis but is NOT a verbatim event +replay; the goal is to pin the BEHAVIORAL SHAPE of each fix so +regressions surface here. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from uuid import uuid4 + +import pytest +from roboco.foundation.policy import lifecycle as spec + +_FIXTURE = Path(__file__).parent.parent / "fixtures" / "2026-05-08-smoke-trace.json" + +# 9 bugs from the 2026-05-08 trace + 2 from the 2026-05-09 follow-up. +_EXPECTED_BUG_COUNT = 11 + + +def _load_fixture() -> dict[str, Any]: + return json.loads(_FIXTURE.read_text()) + + +def _bug_records() -> list[dict[str, Any]]: + return list(_load_fixture()["known_bugs"]) + + +def _id_for(record: dict[str, Any]) -> str: + return str(record["id"]) + + +def _stub_task(record: dict[str, Any]) -> Any: + """Build a minimal task stub matching the fixture record's setup. + + Spec preconditions only read a handful of attributes; we expose + them via SimpleNamespace so the spec can introspect without + needing a full SQLAlchemy Task. + """ + ctx = record.get("context", {}) or {} + actor_id = uuid4() + other_id = uuid4() + owns = bool(ctx.get("owns_task")) or bool(ctx.get("actor_is_owner")) + assigned_to = ( + actor_id if owns else (other_id if ctx.get("task_was_reassigned") else None) + ) + commits_count = int(ctx.get("commits") or 0) + return SimpleNamespace( + id=uuid4(), + status=record["task_status"], + task_type=record["task_type"], + assigned_to=assigned_to, + plan="some plan" if record["task_status"] == "in_progress" else None, + commits=[{"sha": f"abc{i}"} for i in range(commits_count)], + pr_number=ctx.get("pr_number"), + branch_name="feature/backend/abc", + parent_task_id=None, + sequence=0, + team="backend", + title="t", + quick_context=None, + ), actor_id + + +def _build_context(record: dict[str, Any], actor_id: Any) -> spec.Context: + ctx = record.get("context", {}) or {} + return spec.Context( + actor_id=actor_id, + plan=ctx.get("plan"), + ) + + +# --------------------------------------------------------------------------- +# Top-level fixture-shape sanity +# --------------------------------------------------------------------------- + + +def test_fixture_loads_and_has_eleven_records() -> None: + """The fixture must enumerate all 9 + 2 = 11 known bugs.""" + payload = _load_fixture() + assert payload["schema_version"] == 1 + assert payload["trace_date"] == "2026-05-08" + assert payload["follow_up_trace_date"] == "2026-05-09" + records = payload["known_bugs"] + assert len(records) == _EXPECTED_BUG_COUNT, ( + f"Expected 9 (2026-05-08) + 2 (2026-05-09) = {_EXPECTED_BUG_COUNT} " + f"bug records; got {len(records)}. If a bug was added or removed, " + f"update _EXPECTED_BUG_COUNT and document the change in the fixture." + ) + ids = [r["id"] for r in records] + assert len(ids) == len(set(ids)), f"duplicate bug ids: {ids}" + # Every record must declare a replay_kind so the test can dispatch. + valid_kinds = { + "spec_decision", + "spec_decision_role_only", + "spec_decision_then_verb_body", + "spec_introspection", + "schema_only_skip", + "audit_only_skip", + "behavioral_skip", + } + for r in records: + assert r["replay_kind"] in valid_kinds, ( + f"bug {r['id']}: unknown replay_kind {r['replay_kind']!r}" + ) + + +# --------------------------------------------------------------------------- +# Replay - parametrized over every known-bug record +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("record", _bug_records(), ids=_id_for) +def test_known_bug_does_not_recur(record: dict[str, Any]) -> None: + """For each documented bug, assert the post-fix behavior holds. + + Dispatch on `replay_kind`; skip-records carry a `skip_reason` + that documents why the bug is not directly assertable at the + spec layer (the fix lives elsewhere - the schema, the audit + service, or a verb-body behavioral guard). + """ + kind = record["replay_kind"] + + if kind in ("schema_only_skip", "audit_only_skip", "behavioral_skip"): + pytest.skip( + f"bug {record['id']}: {record['skip_reason']} " + f"(fix commit: {record['fix_commit']})" + ) + + if kind == "spec_introspection": + _assert_spec_introspection(record) + return + + if kind == "spec_decision": + _assert_spec_decision(record) + return + + if kind == "spec_decision_role_only": + _assert_spec_decision_role_only(record) + return + + if kind == "spec_decision_then_verb_body": + _assert_spec_decision_allows_verb_body(record) + return + + pytest.fail(f"bug {record['id']}: unhandled replay_kind {kind!r}") + + +# --------------------------------------------------------------------------- +# Per-replay-kind assertion helpers +# --------------------------------------------------------------------------- + + +def _assert_spec_decision(record: dict[str, Any]) -> None: + """Full spec.can_invoke_intent assertion: allow OR specific rejection.""" + task, actor_id = _stub_task(record) + ctx = _build_context(record, actor_id) + role = spec.Role(record["role"]) + decision = spec.can_invoke_intent(role, record["verb"], task, ctx) + + expected_decision = record["expected_post_fix_decision"] + + if expected_decision == "allow": + assert decision.allowed, ( + f"bug {record['id']} regressed: spec.can_invoke_intent rejected " + f"role={role.value} verb={record['verb']} status={record['task_status']} " + f"task_type={record['task_type']}: {decision.rejection_kind} - " + f"{decision.message}. Fix commit was {record['fix_commit']}; " + f"spec invariant: {record['spec_invariant']}" + ) + elif expected_decision == "tracing_gap": + assert not decision.allowed, ( + f"bug {record['id']} regressed: spec allowed but should reject " + f"with tracing_gap. Fix commit was {record['fix_commit']}." + ) + assert decision.rejection_kind == "tracing_gap", ( + f"bug {record['id']} regressed: expected tracing_gap, got " + f"{decision.rejection_kind!r}. Fix commit: {record['fix_commit']}; " + f"spec invariant: {record['spec_invariant']}" + ) + expected_missing = record.get("expected_missing", []) + for token in expected_missing: + assert token in decision.missing, ( + f"bug {record['id']} regressed: missing list " + f"{decision.missing!r} should contain {token!r}. Fix commit: " + f"{record['fix_commit']}" + ) + else: + pytest.fail( + f"bug {record['id']}: unhandled expected_post_fix_decision " + f"{expected_decision!r} for replay_kind=spec_decision" + ) + + +def _assert_spec_decision_role_only(record: dict[str, Any]) -> None: + """For verbs whose spec gate is role-only (composes=()). + + The spec must allow the role; verb-body guards (e.g. + current_owner-hinted reassignment rejections) are pinned by + separate unit tests. The point here is: role authority must + not regress, otherwise the verb-body guard never gets a chance + to fire. + """ + task, actor_id = _stub_task(record) + ctx = _build_context(record, actor_id) + role = spec.Role(record["role"]) + decision = spec.can_invoke_intent(role, record["verb"], task, ctx) + + assert decision.allowed, ( + f"bug {record['id']} regressed: spec.can_invoke_intent rejected " + f"role={role.value} verb={record['verb']} at the role gate; the " + f"verb-body's current_owner-hinted reassignment rejection can never " + f"fire if the role gate rejects first. Fix commit: " + f"{record['fix_commit']}; spec invariant: {record['spec_invariant']}" + ) + + +def _assert_spec_decision_allows_verb_body(record: dict[str, Any]) -> None: + """Spec gate must allow so the verb-body can surface its rejection. + + Bug B (cell-pm-vs-task-type) is enforced in the verb body + (_delegate_static_guards), not the spec. The spec must allow + main_pm to call delegate from in_progress so the verb body's + invalid_state on Cell-PM-assigned-code-typed-subtask can fire. + """ + task, actor_id = _stub_task(record) + ctx = _build_context(record, actor_id) + role = spec.Role(record["role"]) + decision = spec.can_invoke_intent(role, record["verb"], task, ctx) + + assert decision.allowed, ( + f"bug {record['id']} regressed: spec.can_invoke_intent rejected " + f"role={role.value} verb={record['verb']} status={record['task_status']} " + f"task_type={record['task_type']} at the spec layer; the verb-body's " + f"Cell-PM-vs-task-type guard can never fire if the spec gate rejects " + f"first. Fix commit: {record['fix_commit']}; spec invariant: " + f"{record['spec_invariant']}" + ) + + +def _assert_spec_introspection(record: dict[str, Any]) -> None: + """Structural assertions on the spec's verb table. + + Bug 3 / Bug 7: open_pr exists in _INTENT_VERBS and submit_for_qa + does not. Bug 4: spec.intents_for_role is the single source of + truth — the verb set is non-empty for every active role and + every verb a role has access to is declared in the spec. + """ + bug_id = record["id"] + + if bug_id in ( + "bug-3-silent-partial-submit-for-qa", + "bug-7-plan-sdk-vs-gate-disagreement", + ): + # The rename submit_for_qa -> open_pr must hold. + assert "open_pr" in spec._INTENT_VERBS, ( + f"bug {bug_id} regressed: open_pr is no longer declared in " + f"_INTENT_VERBS. Fix commit: {record['fix_commit']}; spec " + f"invariant: {record['spec_invariant']}" + ) + assert "submit_for_qa" not in spec._INTENT_VERBS, ( + f"bug {bug_id} regressed: submit_for_qa was re-introduced into " + f"_INTENT_VERBS. The rename to open_pr must stick. Fix commit: " + f"{record['fix_commit']}" + ) + # And the open_pr IntentSpec carries the atomic preconditions + # so push_branch / create_pr cannot run before commits / no-prior-PR. + open_pr_spec = spec._INTENT_VERBS["open_pr"] + precond_keys = {p.key for p in open_pr_spec.extra_preconditions} + for required in ("owns_task", "commits>=1", "no_prior_pr"): + assert required in precond_keys, ( + f"bug {bug_id} regressed: open_pr.extra_preconditions is " + f"missing {required!r} (got {precond_keys}). Fix commit: " + f"{record['fix_commit']}" + ) + # Developer's spec-derived verb list contains open_pr (and not + # submit_for_qa) - the agent-facing surface is uniform. + dev_verbs = set(spec.intents_for_role(spec.Role.DEVELOPER)) + assert "open_pr" in dev_verbs + assert "submit_for_qa" not in dev_verbs + return + + if bug_id == "bug-4-scattered-role-checks-disagreed": + # Single source of truth: every role active in the org has a + # non-empty verb list, and every verb is reachable from at + # least one role. (verb_gates.py has been deleted in commit + # bdeedd8; if anyone re-introduces a parallel role-x-state + # table this test won't catch them, but the import-time + # validators will - this assertion just pins that the spec + # itself is internally complete.) + # SYSTEM is a sentinel role (orchestrator-generated rows) — it has + # no verbs by design. AUDITOR and CEO are excluded because the + # original bug was about active developer/qa/pm/doc roles. + active_roles = [ + r + for r in spec.Role + if r not in (spec.Role.AUDITOR, spec.Role.CEO, spec.Role.SYSTEM) + ] + for r in active_roles: + verbs = spec.intents_for_role(r) + assert verbs, ( + f"bug {bug_id} regressed: role {r.value} has no verbs in " + f"the spec. Fix commit: {record['fix_commit']}" + ) + all_assigned_verbs: set[str] = set() + for r in spec.Role: + all_assigned_verbs.update(spec.intents_for_role(r)) + for verb_name in spec._INTENT_VERBS: + assert verb_name in all_assigned_verbs, ( + f"bug {bug_id} regressed: verb {verb_name!r} is declared in " + f"_INTENT_VERBS but not reachable from any role. Fix commit: " + f"{record['fix_commit']}" + ) + return + + pytest.fail( + f"bug {bug_id}: unhandled spec_introspection record. Add a branch " + f"to _assert_spec_introspection." + ) diff --git a/tests/foundation/test_lifecycle_spec.py b/tests/foundation/test_lifecycle_spec.py new file mode 100644 index 00000000..6bb8b10c --- /dev/null +++ b/tests/foundation/test_lifecycle_spec.py @@ -0,0 +1,696 @@ +"""Tier 1 — spec self-tests. Fast (no DB, no network).""" + +from __future__ import annotations + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from roboco.foundation import _validate_lifecycle as _validate +from roboco.foundation._validate_lifecycle import reachable_from +from roboco.foundation.policy import lifecycle as spec +from roboco.foundation.policy.lifecycle import _INTENT_VERBS, IntentSpec +from roboco.models.base import TaskType as ModelTaskType + + +def test_role_enum_has_every_pre_gateway_role() -> None: + """Every role from PERMISSIONS.md must be enumerated. + + The canonical Role enum is now defined in `roboco.foundation.identity` + and re-exported here. It includes the 9 pre-gateway roles plus the + SYSTEM sentinel used for orchestrator-generated rows. The pre-gateway + PERMISSIONS.md is the historical canon — SYSTEM is the post-foundation + addition that doesn't appear in policy tables. + """ + expected = { + "developer", + "qa", + "documenter", + "cell_pm", + "main_pm", + "product_owner", + "head_marketing", + "auditor", + "ceo", + "system", + } + actual = {r.value for r in spec.Role} + assert actual == expected, f"Role enum drift: {actual ^ expected}" + + +def test_status_enum_has_every_pre_gateway_status() -> None: + """Every status from STATUS_TRANSITIONS.md must be enumerated.""" + expected = { + "backlog", + "pending", + "claimed", + "in_progress", + "blocked", + "paused", + "verifying", + "awaiting_qa", + "needs_revision", + "awaiting_documentation", + "awaiting_pm_review", + "awaiting_ceo_approval", + "completed", + "cancelled", + } + actual = {s.value for s in spec.Status} + assert actual == expected, f"Status enum drift: {actual ^ expected}" + + +def test_task_type_enum_matches_models() -> None: + """The spec's TaskType must match the existing models.base.TaskType. + + If the existing model adds/removes a type, the spec must be updated + in lockstep — that's the entire point of this module. + """ + spec_values = {t.value for t in spec.TaskType} + model_values = {t.value for t in ModelTaskType} + assert spec_values == model_values, ( + f"TaskType drift between lifecycle.spec and models.base: " + f"{spec_values ^ model_values}" + ) + + +def test_decision_allow_has_no_rejection_kind() -> None: + d = spec.Decision.allow() + assert d.allowed is True + assert d.rejection_kind is None + assert d.message is None + assert d.missing == [] + assert d.remediate is None + + +def test_decision_reject_requires_rejection_kind() -> None: + d = spec.Decision.reject( + kind="not_authorized", + message="role 'developer' may not call delegate", + remediate="only PMs delegate; call give_me_work() instead", + ) + assert d.allowed is False + assert d.rejection_kind == "not_authorized" + assert d.message == "role 'developer' may not call delegate" + assert d.remediate == "only PMs delegate; call give_me_work() instead" + + +def test_decision_tracing_gap_carries_missing_list() -> None: + d = spec.Decision.tracing_gap( + missing=["plan", "journal:decision"], + remediate="provide plan and a journal:decision entry", + ) + assert d.allowed is False + assert d.rejection_kind == "tracing_gap" + assert d.missing == ["plan", "journal:decision"] + assert d.remediate == "provide plan and a journal:decision entry" + + +def test_decision_tracing_gap_defensively_copies_missing() -> None: + """tracing_gap must isolate the stored list from the caller's source.""" + src = ["plan"] + d = spec.Decision.tracing_gap(missing=src, remediate="r") + src.append("mutated") + assert d.missing == ["plan"] + + +def test_decision_invariants_enforced_at_construction() -> None: + """allowed=True ⇒ rejection_kind None; allowed=False ⇒ kind set.""" + with pytest.raises(ValueError, match="allowed=True requires rejection_kind=None"): + spec.Decision( + allowed=True, + rejection_kind="not_authorized", + message="x", + missing=[], + remediate="x", + ) + with pytest.raises(ValueError, match="allowed=False requires rejection_kind"): + spec.Decision( + allowed=False, + rejection_kind=None, + message="x", + missing=[], + remediate="x", + ) + + +def test_decision_invariant_rejects_allowed_with_missing_or_remediate() -> None: + """allowed=True with missing or remediate set raises (Fix 1 lock-in).""" + with pytest.raises( + ValueError, match="allowed=True requires missing=\\[\\] and remediate=None" + ): + spec.Decision( + allowed=True, + rejection_kind=None, + message=None, + missing=["plan"], + remediate=None, + ) + with pytest.raises( + ValueError, + match="allowed=True requires missing=\\[\\] and remediate=None", + ): + spec.Decision( + allowed=True, + rejection_kind=None, + message=None, + missing=[], + remediate="oops", + ) + + +def test_precondition_check_returns_bool() -> None: + """A Precondition.check() is the gate-table evaluator.""" + p = spec.Precondition( + key="commits>=1", + check=lambda task, _agent, _ctx: bool(getattr(task, "commits", None)), + remediate="commit at least once before opening a PR", + missing_token="commits>=1", + ) + + task_with = SimpleNamespace(commits=["abc"]) + task_without = SimpleNamespace(commits=[]) + assert p.check(task_with, None, None) is True + assert p.check(task_without, None, None) is False + + +def test_action_spec_holds_role_status_and_precondition_data() -> None: + a = spec.ActionSpec( + name="claim", + allowed_roles=frozenset({spec.Role.DEVELOPER}), + source_statuses=frozenset({spec.Status.PENDING, spec.Status.NEEDS_REVISION}), + target_status=spec.Status.CLAIMED, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=True, + ) + assert a.name == "claim" + assert spec.Role.DEVELOPER in a.allowed_roles + assert a.target_status == spec.Status.CLAIMED + + +def test_intent_spec_composes_atomic_actions() -> None: + i = spec.IntentSpec( + name="i_will_work_on", + allowed_roles=frozenset({spec.Role.DEVELOPER}), + description="Claim a task and start work on it.", + composes=("claim", "set_plan", "start"), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: "edit + commit, then open_pr", + ) + assert i.composes == ("claim", "set_plan", "start") + assert i.next_hint(None) == "edit + commit, then open_pr" + + +def test_status_transition_carries_role_constraint_optional() -> None: + t = spec.StatusTransition( + source=spec.Status.AWAITING_QA, + target=spec.Status.AWAITING_DOCUMENTATION, + triggered_by_action="qa_pass", + role_constraint=frozenset({spec.Role.QA}), + ) + assert t.source == spec.Status.AWAITING_QA + assert t.target == spec.Status.AWAITING_DOCUMENTATION + assert t.triggered_by_action == "qa_pass" + assert t.role_constraint == frozenset({spec.Role.QA}) + + +def test_status_transitions_includes_dev_path() -> None: + """The dev happy path: pending → claimed → in_progress → verifying → awaiting_qa.""" + sources = {(t.source, t.target) for t in spec._STATUS_TRANSITIONS} + assert (spec.Status.PENDING, spec.Status.CLAIMED) in sources + assert (spec.Status.CLAIMED, spec.Status.IN_PROGRESS) in sources + assert (spec.Status.IN_PROGRESS, spec.Status.VERIFYING) in sources + assert (spec.Status.VERIFYING, spec.Status.AWAITING_QA) in sources + + +def test_status_transitions_includes_qa_paths() -> None: + sources = {(t.source, t.target) for t in spec._STATUS_TRANSITIONS} + assert (spec.Status.AWAITING_QA, spec.Status.CLAIMED) in sources # QA claims + assert (spec.Status.AWAITING_QA, spec.Status.AWAITING_DOCUMENTATION) in sources + assert (spec.Status.AWAITING_QA, spec.Status.NEEDS_REVISION) in sources + + +def test_status_transitions_includes_ceo_paths() -> None: + sources = {(t.source, t.target) for t in spec._STATUS_TRANSITIONS} + assert (spec.Status.AWAITING_PM_REVIEW, spec.Status.COMPLETED) in sources + assert ( + spec.Status.AWAITING_PM_REVIEW, + spec.Status.AWAITING_CEO_APPROVAL, + ) in sources + assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.COMPLETED) in sources + assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.NEEDS_REVISION) in sources + + +def test_status_transitions_includes_block_pause_paths() -> None: + sources = {(t.source, t.target) for t in spec._STATUS_TRANSITIONS} + assert (spec.Status.IN_PROGRESS, spec.Status.BLOCKED) in sources + assert (spec.Status.IN_PROGRESS, spec.Status.PAUSED) in sources + assert (spec.Status.BLOCKED, spec.Status.IN_PROGRESS) in sources + assert (spec.Status.PAUSED, spec.Status.IN_PROGRESS) in sources + + +def test_every_non_terminal_status_can_be_cancelled() -> None: + """PERMISSIONS.md says PM/CEO can cancel from any state.""" + cancellable = { + t.source for t in spec._STATUS_TRANSITIONS if t.target == spec.Status.CANCELLED + } + non_terminal = set(spec.Status) - {spec.Status.COMPLETED, spec.Status.CANCELLED} + assert non_terminal <= cancellable, ( + f"Statuses missing a cancel transition: {non_terminal - cancellable}" + ) + + +def test_status_graph_lookup_returns_targets() -> None: + """STATUS_GRAPH is a quick `source -> {targets}` lookup.""" + assert spec.Status.CLAIMED in spec.STATUS_GRAPH[spec.Status.PENDING] + assert spec.Status.AWAITING_QA in spec.STATUS_GRAPH[spec.Status.VERIFYING] + assert spec.STATUS_GRAPH[spec.Status.COMPLETED] == frozenset() + + +def test_status_transitions_role_constraints_match_canon() -> None: + """role_constraint must encode the per-row role gates from + PERMISSIONS.md / STATUS_TRANSITIONS.md exactly. Tests that look only + at (source, target) pairs miss role-typo regressions; this test + pins the gates explicitly. + """ + by_pair = { + (t.source, t.target, t.triggered_by_action): t.role_constraint + for t in spec._STATUS_TRANSITIONS + } + # QA is the only role that can claim awaiting_qa + assert by_pair[ + (spec.Status.AWAITING_QA, spec.Status.CLAIMED, "claim") + ] == frozenset({spec.Role.QA}) + # Documenter is the only role that can claim awaiting_documentation + assert by_pair[ + (spec.Status.AWAITING_DOCUMENTATION, spec.Status.CLAIMED, "claim") + ] == frozenset({spec.Role.DOCUMENTER}) + # qa_pass / qa_fail: QA only + assert by_pair[ + (spec.Status.AWAITING_QA, spec.Status.AWAITING_DOCUMENTATION, "qa_pass") + ] == frozenset({spec.Role.QA}) + assert by_pair[ + (spec.Status.AWAITING_QA, spec.Status.NEEDS_REVISION, "qa_fail") + ] == frozenset({spec.Role.QA}) + # docs_complete: documenter only + assert by_pair[ + ( + spec.Status.AWAITING_DOCUMENTATION, + spec.Status.AWAITING_PM_REVIEW, + "docs_complete", + ) + ] == frozenset({spec.Role.DOCUMENTER}) + # PM complete: cell + main PM (not board, not CEO) + assert by_pair[ + (spec.Status.AWAITING_PM_REVIEW, spec.Status.COMPLETED, "complete") + ] == frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM}) + # escalate_to_ceo: main_pm + product_owner + head_marketing + assert by_pair[ + ( + spec.Status.AWAITING_PM_REVIEW, + spec.Status.AWAITING_CEO_APPROVAL, + "escalate_to_ceo", + ) + ] == frozenset( + { + spec.Role.MAIN_PM, + spec.Role.PRODUCT_OWNER, + spec.Role.HEAD_MARKETING, + } + ) + # CEO actions: CEO only + assert by_pair[ + (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.COMPLETED, "ceo_approve") + ] == frozenset({spec.Role.CEO}) + assert by_pair[ + (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.NEEDS_REVISION, "ceo_reject") + ] == frozenset({spec.Role.CEO}) + # Cancel: PM + CEO from any non-terminal status + cancel_constraint = frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM, spec.Role.CEO}) + for src in spec.Status: + if src in (spec.Status.COMPLETED, spec.Status.CANCELLED): + continue + assert by_pair[(src, spec.Status.CANCELLED, "cancel")] == cancel_constraint, ( + f"cancel from {src.value} has wrong role_constraint" + ) + + +def test_atomic_action_table_has_pre_gateway_actions() -> None: + """Every task tool from PERMISSIONS.md must have an ActionSpec.""" + expected = { + "activate", + "claim", + "start", + "set_plan", + "block", + "unblock", + "pause", + "resume", + "submit_verification", + "submit_qa", + "qa_pass", + "qa_fail", + "docs_complete", + "complete", + "submit_pm_review", + "escalate_to_ceo", + "ceo_approve", + "ceo_reject", + "cancel", + "create_subtask", + } + assert expected <= set(spec._ATOMIC_ACTIONS), ( + f"Missing ActionSpec entries: {expected - set(spec._ATOMIC_ACTIONS)}" + ) + + +def test_claim_action_allows_developer_from_pending() -> None: + a = spec._ATOMIC_ACTIONS["claim"] + assert spec.Role.DEVELOPER in a.allowed_roles + assert spec.Status.PENDING in a.source_statuses + assert a.target_status == spec.Status.CLAIMED + + +def test_qa_pass_self_review_blocks() -> None: + """A QA cannot qa_pass a task they themselves committed to.""" + assert spec._ATOMIC_ACTIONS["qa_pass"].self_review_block is True + assert spec._ATOMIC_ACTIONS["qa_fail"].self_review_block is True + assert spec._ATOMIC_ACTIONS["docs_complete"].self_review_block is True + + +def test_claim_rules_match_pre_gateway_table() -> None: + """PERMISSIONS.md "What Each Role Can Claim From" — exact match. + + PMs claim from PENDING only; BACKLOG → PENDING is a separate `activate` + action (strict transitions; no implicit activate-on-claim). + """ + assert spec.CLAIM_RULES[spec.Role.DEVELOPER] == frozenset( + {spec.Status.PENDING, spec.Status.NEEDS_REVISION} + ) + assert spec.CLAIM_RULES[spec.Role.QA] == frozenset({spec.Status.AWAITING_QA}) + assert spec.CLAIM_RULES[spec.Role.DOCUMENTER] == frozenset( + {spec.Status.PENDING, spec.Status.AWAITING_DOCUMENTATION} + ) + assert spec.CLAIM_RULES[spec.Role.CELL_PM] == frozenset({spec.Status.PENDING}) + assert spec.CLAIM_RULES[spec.Role.MAIN_PM] == frozenset({spec.Status.PENDING}) + + +def test_team_rules_pin_team_for_seeded_agents() -> None: + assert spec.ROLE_TEAM_RULES["be-dev-1"] == "backend" + assert spec.ROLE_TEAM_RULES["be-pm"] == "backend" + assert spec.ROLE_TEAM_RULES["fe-qa"] == "frontend" + assert spec.ROLE_TEAM_RULES["main-pm"] is None # cross-cell + + +def test_intent_verbs_table_has_every_gateway_verb() -> None: + """Every gateway intent verb must have an IntentSpec.""" + expected = { + "give_me_work", + "i_will_work_on", + "i_will_plan", + "delegate", + "open_pr", + "i_am_done", + "i_am_blocked", + "unclaim", + "resume", + "i_am_idle", + "claim_review", + "pass_review", + "fail_review", + "claim_doc_task", + "i_documented", + "complete", + "escalate_up", + "escalate_to_ceo", + "submit_up", + "unblock", + "triage", + "triage_all", + } + assert expected <= set(spec._INTENT_VERBS), ( + f"Missing IntentSpec entries: {expected - set(spec._INTENT_VERBS)}" + ) + + +def test_i_will_work_on_composes_claim_set_plan_start() -> None: + iv = spec._INTENT_VERBS["i_will_work_on"] + assert iv.composes == ("claim", "set_plan", "start") + assert spec.Role.DEVELOPER in iv.allowed_roles + + +def test_i_will_plan_composes_claim_set_plan_start() -> None: + """PMs use i_will_plan; the composition mirrors i_will_work_on.""" + iv = spec._INTENT_VERBS["i_will_plan"] + assert iv.composes == ("claim", "set_plan", "start") + assert iv.allowed_roles == frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM}) + + +def test_i_am_done_composes_submit_verification_then_submit_qa() -> None: + iv = spec._INTENT_VERBS["i_am_done"] + assert iv.composes == ("submit_verification", "submit_qa") + + +def test_open_pr_has_git_side_effects() -> None: + """open_pr is a side-effect-only verb (no DB transition).""" + iv = spec._INTENT_VERBS["open_pr"] + assert "push_branch" in iv.side_effects + assert "create_pr" in iv.side_effects + assert iv.composes == () # pure side effect verb + + +def test_delegate_composes_create_subtask() -> None: + iv = spec._INTENT_VERBS["delegate"] + assert iv.composes == ("create_subtask",) + assert iv.allowed_roles == frozenset({spec.Role.CELL_PM, spec.Role.MAIN_PM}) + + +_STUB_TASK_DEFAULTS = { + "status": "pending", + "task_type": "code", + "commits": [], + "plan": None, + "assigned_to": None, + "pr_number": None, +} + + +def _stub_task(**overrides): + fields = {**_STUB_TASK_DEFAULTS, **overrides} + fields["commits"] = fields["commits"] or [] + return SimpleNamespace(**fields) + + +def test_can_claim_developer_pending_allowed() -> None: + d = spec.can_claim(spec.Role.DEVELOPER, _stub_task(status="pending")) + assert d.allowed is True + + +def test_can_claim_developer_completed_rejected() -> None: + d = spec.can_claim(spec.Role.DEVELOPER, _stub_task(status="completed")) + assert d.allowed is False + assert d.rejection_kind == "invalid_state" + + +def test_can_claim_developer_awaiting_qa_rejected() -> None: + """Devs cannot claim awaiting_qa - that's QA's path.""" + d = spec.can_claim(spec.Role.DEVELOPER, _stub_task(status="awaiting_qa")) + assert d.allowed is False + assert d.rejection_kind == "not_authorized" + + +def test_can_invoke_intent_developer_can_call_i_will_work_on() -> None: + d = spec.can_invoke_intent( + spec.Role.DEVELOPER, + "i_will_work_on", + _stub_task(status="pending"), + context=spec.Context(plan="my plan"), + ) + assert d.allowed is True + + +def test_can_invoke_intent_pm_cannot_call_i_will_work_on() -> None: + """PMs use i_will_plan; i_will_work_on is dev-only.""" + d = spec.can_invoke_intent( + spec.Role.CELL_PM, + "i_will_work_on", + _stub_task(status="pending"), + context=spec.Context(plan="x"), + ) + assert d.allowed is False + assert d.rejection_kind == "not_authorized" + + +def test_can_invoke_intent_developer_open_pr_no_commits_tracing_gap() -> None: + """open_pr requires >=1 commit. Without one -> tracing_gap.""" + d = spec.can_invoke_intent( + spec.Role.DEVELOPER, + "open_pr", + _stub_task(status="in_progress", commits=[]), + context=spec.Context(), + ) + assert d.allowed is False + assert d.rejection_kind == "tracing_gap" + assert "commits>=1" in d.missing + + +def test_valid_next_verbs_developer_in_progress_includes_open_pr_and_i_am_done() -> ( + None +): + verbs = spec.valid_next_verbs(spec.Role.DEVELOPER, _stub_task(status="in_progress")) + assert "open_pr" in verbs + assert "i_am_done" in verbs + assert "i_am_blocked" in verbs + + +def test_valid_next_verbs_pm_pending_includes_i_will_plan() -> None: + verbs = spec.valid_next_verbs(spec.Role.CELL_PM, _stub_task(status="pending")) + assert "i_will_plan" in verbs + + +def test_composed_actions_for_returns_intent_composition() -> None: + assert spec.composed_actions_for("i_will_work_on") == ("claim", "set_plan", "start") + assert spec.composed_actions_for("open_pr") == () + + +def test_intents_for_role_returns_role_scoped_verbs() -> None: + dev_verbs = spec.intents_for_role(spec.Role.DEVELOPER) + assert "i_will_work_on" in dev_verbs + assert "open_pr" in dev_verbs + assert "i_am_done" in dev_verbs + assert "delegate" not in dev_verbs # PM only + assert "claim_review" not in dev_verbs # QA only + + +def test_status_after_returns_target_status() -> None: + assert spec.status_after("claim", spec.Status.PENDING) == spec.Status.CLAIMED + assert ( + spec.status_after("submit_qa", spec.Status.VERIFYING) == spec.Status.AWAITING_QA + ) + assert ( + spec.status_after("set_plan", spec.Status.IN_PROGRESS) is None + ) # no transition + + +def test_can_invoke_intent_open_pr_passes_when_owner_with_commits() -> None: + """Green path for open_pr: owner + commits + no prior PR → allow.""" + owner_id = uuid4() + task = _stub_task( + status="in_progress", + commits=["abc"], + pr_number=None, + assigned_to=owner_id, + ) + d = spec.can_invoke_intent( + spec.Role.DEVELOPER, + "open_pr", + task, + context=spec.Context(actor_id=owner_id), + ) + assert d.allowed is True, f"expected allow, got {d}" + + +def test_can_invoke_intent_open_pr_rejects_non_owner() -> None: + """Non-owner trying open_pr → tracing_gap with owns_task missing.""" + owner_id = uuid4() + intruder_id = uuid4() + task = _stub_task( + status="in_progress", + commits=["abc"], + pr_number=None, + assigned_to=owner_id, + ) + d = spec.can_invoke_intent( + spec.Role.DEVELOPER, + "open_pr", + task, + context=spec.Context(actor_id=intruder_id), + ) + assert d.allowed is False + assert d.rejection_kind == "tracing_gap" + assert "owns_task" in d.missing + + +# --------------------------------------------------------------------------- +# Task 8 — self-consistency validators (`_validate.py`) +# --------------------------------------------------------------------------- + + +def test_validators_pass_on_real_spec() -> None: + """Importing roboco.foundation.policy.lifecycle must not raise — + module-level import IS the test. We additionally call the runner + directly so a future refactor that detaches it from import doesn't + silently skip the gate. + """ + _validate.run_all_lifecycle_validators() + + +def test_every_status_reachable_from_pending() -> None: + """Reachability — except CANCELLED is its own thing and BACKLOG predates pending.""" + reachable = reachable_from(spec.Status.PENDING) + expected_reachable = set(spec.Status) - {spec.Status.BACKLOG, spec.Status.CANCELLED} + assert expected_reachable <= reachable, ( + f"Unreachable from pending: {expected_reachable - reachable}" + ) + + +def test_every_intent_verb_composes_known_actions() -> None: + """Every IntentSpec.composes must reference declared atomic actions.""" + for name, iv in spec._INTENT_VERBS.items(): + for action_name in iv.composes: + assert action_name in spec._ATOMIC_ACTIONS, ( + f"Intent '{name}' composes unknown action '{action_name}'" + ) + + +def test_self_review_symmetry() -> None: + """If qa_pass blocks, qa_fail and docs_complete must too.""" + qp = spec._ATOMIC_ACTIONS["qa_pass"].self_review_block + qf = spec._ATOMIC_ACTIONS["qa_fail"].self_review_block + dc = spec._ATOMIC_ACTIONS["docs_complete"].self_review_block + assert qp == qf == dc, ( + "self_review_block asymmetry between qa_pass/qa_fail/docs_complete" + ) + + +def test_run_all_validators_raises_on_unknown_intent_action( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an IntentSpec.composes references a non-existent action, the + validator must raise LifecycleSpecError. Pins the gate's actual + behavior — without this test, refactors that move run_all_validators() + out of the import path could silently disable the gate. + """ + iv = _INTENT_VERBS["delegate"] + broken = IntentSpec( + name=iv.name, + allowed_roles=iv.allowed_roles, + description=iv.description, + composes=("create_subtask", "ZZZ_FAKE_ACTION_DOES_NOT_EXIST"), + extra_preconditions=iv.extra_preconditions, + side_effects=iv.side_effects, + next_hint=iv.next_hint, + ) + patched_intents = dict(_INTENT_VERBS) + patched_intents["delegate"] = broken + monkeypatch.setattr( + "roboco.foundation.policy.lifecycle._INTENT_VERBS", patched_intents + ) + with pytest.raises(_validate.LifecycleSpecError, match="ZZZ_FAKE_ACTION"): + _validate.run_all_lifecycle_validators() + + +def test_unmigrated_is_pinned() -> None: + """The known-debt set; remove an entry once that consumer is migrated.""" + assert ( + frozenset( + { + "enforcement.task_lifecycle._LEGACY_OPERATIONAL_EDGES", + "enforcement.task_lifecycle._LEGACY_ROLE_GATES", + } + ) + == spec.UNMIGRATED + ) diff --git a/tests/foundation/test_role_reexport.py b/tests/foundation/test_role_reexport.py new file mode 100644 index 00000000..be8218a1 --- /dev/null +++ b/tests/foundation/test_role_reexport.py @@ -0,0 +1,24 @@ +"""Verify that the Role / Team re-export shims point to foundation.identity. + +After migration, ``models.base.AgentRole`` and ``models.base.Team`` MUST be +the SAME Python object as the canonical ``foundation.identity.Role`` / +``foundation.identity.Team``. Object identity (`is`) is the assertion — +anything weaker allows silent re-forking. + +The previous ``roboco.lifecycle.spec`` re-export shim was deleted in +Phase 4 Task 8; the two tests that asserted ``spec.Role is identity.Role`` +went away with it. +""" + +from __future__ import annotations + +from roboco.foundation import identity +from roboco.models.base import AgentRole, Team + + +def test_models_base_agentrole_is_foundation_role() -> None: + assert AgentRole is identity.Role + + +def test_models_base_team_is_foundation_team() -> None: + assert Team is identity.Team diff --git a/tests/foundation/test_role_set_parity.py b/tests/foundation/test_role_set_parity.py new file mode 100644 index 00000000..3d615387 --- /dev/null +++ b/tests/foundation/test_role_set_parity.py @@ -0,0 +1,41 @@ +"""Role-sets are now canonical in foundation; consumers must derive.""" + +from __future__ import annotations + +from roboco.agents_config import _BOARD_ROLES, TASK_CREATOR_ROLES +from roboco.foundation import identity +from roboco.services import permissions as perms + + +def test_agents_config_task_creator_roles_is_5_roles() -> None: + """The 5-role 'PMs+board+CEO' set in agents_config is renamed to TASK_CREATOR_ROLES. + + Pre-migration: agents_config.py defined PM_ROLES = {cell_pm, main_pm, + product_owner, head_marketing, ceo} — but this is the "roles that can + create tasks", NOT the PM hierarchy. Renamed to TASK_CREATOR_ROLES. + """ + expected = frozenset( + { + identity.Role.CELL_PM, + identity.Role.MAIN_PM, + identity.Role.PRODUCT_OWNER, + identity.Role.HEAD_MARKETING, + identity.Role.CEO, + } + ) + assert expected == TASK_CREATOR_ROLES + + +def test_services_permissions_pm_roles_is_foundation() -> None: + """services/permissions.PM_ROLES (2-role variant) is now foundation.PM_ROLES. + + Object identity (`is`) — not just equality. + """ + assert perms.PM_ROLES is identity.PM_ROLES + + +def test_agents_config_board_roles_aliased_to_foundation() -> None: + """agents_config._BOARD_ROLES is foundation.BOARD_ROLES (3 roles, no main_pm).""" + assert _BOARD_ROLES == identity.BOARD_ROLES + # Specifically: no MAIN_PM (main_pm is below board, not part of it). + assert identity.Role.MAIN_PM not in _BOARD_ROLES diff --git a/tests/foundation/test_route_guard_consolidation.py b/tests/foundation/test_route_guard_consolidation.py new file mode 100644 index 00000000..011d52b2 --- /dev/null +++ b/tests/foundation/test_route_guard_consolidation.py @@ -0,0 +1,77 @@ +"""Route-guard role-sets in api/deps + api/routes/v2/_role_dep derive from foundation. + +The HTTP-layer guards in `roboco.api.deps` and `roboco.api.routes.v2._role_dep` +historically used hand-written frozensets of role-name strings. Phase 4 Task 11 +moves those literals onto `foundation.identity` so adding/renaming a role only +edits one file. These tests pin the foundation-derived membership and the +import contract. +""" + +from __future__ import annotations + +import inspect + +from roboco.api import deps +from roboco.api.deps import ( + _DEVELOPER_OR_ABOVE_ROLES, + _GLOBAL_CELL_ACCESS_ROLES, + _PM_OR_ABOVE_ROLES, +) +from roboco.api.routes.v2 import _role_dep +from roboco.foundation.identity import BOARD_ROLES, DEV_ROLES, PM_ROLES, Role + + +def test_pm_or_above_roles_matches_foundation_composition() -> None: + """`require_pm_or_above` admits PMs + non-marketing board + CEO. + + Composition: PM_ROLES | (BOARD_ROLES - {HEAD_MARKETING}) | {CEO}. + Head-marketing is intentionally excluded — the role is a marketing + spokesperson, not a workflow approver. + """ + expected = PM_ROLES | (BOARD_ROLES - {Role.HEAD_MARKETING}) | {Role.CEO} + assert expected == _PM_OR_ABOVE_ROLES + + +def test_developer_or_above_roles_matches_foundation_composition() -> None: + """`require_developer_or_above` admits developers + PM-or-above. + + Composition: DEV_ROLES | (PM_OR_ABOVE). + QA and documenter are intentionally NOT in this set — work-session + create/commit/PR endpoints are dev-only operations. + """ + expected = DEV_ROLES | _PM_OR_ABOVE_ROLES + assert expected == _DEVELOPER_OR_ABOVE_ROLES + + +def test_global_cell_access_roles_matches_foundation_composition() -> None: + """`require_cell_access` lets main-PM + non-marketing board + CEO cross cells.""" + expected = (BOARD_ROLES - {Role.HEAD_MARKETING}) | {Role.MAIN_PM, Role.CEO} + assert expected == _GLOBAL_CELL_ACCESS_ROLES + + +def test_deps_module_imports_from_foundation() -> None: + """`roboco.api.deps` sources its role-set primitives from foundation.""" + src = inspect.getsource(deps) + assert "from roboco.foundation.identity import" in src + + +def test_role_dep_module_imports_from_foundation() -> None: + """`roboco.api.routes.v2._role_dep` sources its Role enum from foundation.""" + src = inspect.getsource(_role_dep) + assert "from roboco.foundation.identity import" in src + + +def test_v2_role_dep_sets_match_foundation_roles() -> None: + """v2 single-role guards use foundation Role values, not raw strings.""" + # Single-role guards should pass through Role-typed frozensets so + # renaming a role lives in foundation, not the guard literal. + # The Depends() objects wrap the closure, so we can't introspect the + # frozenset directly — but we can confirm the role values resolve. + assert Role.DEVELOPER == "developer" + assert Role.QA == "qa" + assert Role.DOCUMENTER == "documenter" + assert Role.CELL_PM == "cell_pm" + assert Role.MAIN_PM == "main_pm" + assert Role.PRODUCT_OWNER == "product_owner" + assert Role.HEAD_MARKETING == "head_marketing" + assert Role.AUDITOR == "auditor" diff --git a/tests/foundation/test_seed_orchestrator_parity.py b/tests/foundation/test_seed_orchestrator_parity.py new file mode 100644 index 00000000..f1b96d4f --- /dev/null +++ b/tests/foundation/test_seed_orchestrator_parity.py @@ -0,0 +1,53 @@ +"""Seed and orchestrator agent maps must derive from foundation.""" + +from __future__ import annotations + +from roboco.foundation import identity +from roboco.runtime import orchestrator +from roboco.seeds.initial_data import AGENT_UUIDS, DEFAULT_AGENTS + + +def test_seed_agent_uuids_derive_from_foundation() -> None: + """seeds.initial_data.AGENT_UUIDS is derived; not hand-maintained.""" + expected = {slug: str(row.uuid) for slug, row in identity.AGENTS.items()} + assert expected == AGENT_UUIDS, ( + f"AGENT_UUIDS drift from foundation: {set(expected) ^ set(AGENT_UUIDS)}" + ) + + +def test_default_agents_derive_role_team_from_foundation() -> None: + """DEFAULT_AGENTS rows must use the foundation-declared role+team.""" + by_slug = {row["slug"]: row for row in DEFAULT_AGENTS} + for slug, row in identity.AGENTS.items(): + if slug == "system": + # system may or may not be in DEFAULT_AGENTS depending on bootstrap + continue + assert slug in by_slug, f"{slug} missing from DEFAULT_AGENTS" + assert by_slug[slug]["role"] == row.role.value, ( + f"role drift for {slug}: seed={by_slug[slug]['role']}, " + f"foundation={row.role.value}" + ) + assert by_slug[slug]["team"] == row.team.value, ( + f"team drift for {slug}: seed={by_slug[slug]['team']}, " + f"foundation={row.team.value}" + ) + + +def test_head_marketing_seeded_as_board() -> None: + """Resolves the head-marketing drift in the seed file.""" + head = next(r for r in DEFAULT_AGENTS if r["slug"] == "head-marketing") + assert head["team"] == "board" + + +def test_orchestrator_team_resolution_uses_foundation() -> None: + """orchestrator's team resolution agrees with foundation for every slug.""" + if hasattr(orchestrator, "_AGENT_TEAM_MAP"): + for slug, team_str in orchestrator._AGENT_TEAM_MAP.items(): + assert team_str == identity.team_for_slug(slug).value, ( + f"team drift for {slug}: orchestrator={team_str}, " + f"foundation={identity.team_for_slug(slug).value}" + ) + + # Resolution by behavior: + assert identity.team_for_slug("be-dev-1") == identity.Team.BACKEND + assert identity.team_for_slug("head-marketing") == identity.Team.BOARD diff --git a/tests/foundation/test_task_completeness.py b/tests/foundation/test_task_completeness.py new file mode 100644 index 00000000..9372cd52 --- /dev/null +++ b/tests/foundation/test_task_completeness.py @@ -0,0 +1,153 @@ +"""Tier 1 — task-completeness rules.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from roboco.foundation.policy import task_completeness as tc + +MIN_HINT_LEN = 20 +PARENT_PRIORITY_HIGH = 4 # parent task priority used for inheritance assertions +DEFAULT_PRIORITY_MEDIUM = 2 # fill_priority_from_parent default when no parent + + +def _task(**fields): + """Build a SimpleNamespace mimicking a Task with the given fields.""" + defaults = { + "title": "ok", + "description": "x" * 30, + "acceptance_criteria": ["criterion one"], + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "team": "backend", + } + defaults.update(fields) + return SimpleNamespace(**defaults) + + +def test_task_at_create_passes_for_complete_task() -> None: + result = tc.check(tc.TASK_AT_CREATE, _task()) + assert result.passed is True + assert result.missing == [] + + +def test_task_at_create_rejects_empty_acceptance_criteria() -> None: + result = tc.check(tc.TASK_AT_CREATE, _task(acceptance_criteria=[])) + assert result.passed is False + assert "acceptance_criteria" in result.missing + + +def test_task_at_create_rejects_short_description() -> None: + result = tc.check(tc.TASK_AT_CREATE, _task(description="too short")) + assert result.passed is False + assert "description" in result.missing + + +def test_task_at_create_rejects_missing_nature() -> None: + result = tc.check(tc.TASK_AT_CREATE, _task(nature=None)) + assert result.passed is False + assert "nature" in result.missing + + +def test_denylist_rejects_silent_fallback_phrase() -> None: + """Specifically catch the deleted task.py:5061 placeholder.""" + bad = _task(acceptance_criteria=["completed and reviewed by assignee"]) + result = tc.check(tc.TASK_AT_CREATE, bad) + assert result.passed is False + assert "acceptance_criteria" in result.missing + assert any("placeholder" in h.lower() for h in result.field_hints.values()) + + +def test_denylist_rejects_see_title_description() -> None: + result = tc.check(tc.TASK_AT_CREATE, _task(description="see title")) + assert result.passed is False + assert "description" in result.missing + + +def test_denylist_rejects_todo_description() -> None: + result = tc.check(tc.TASK_AT_CREATE, _task(description="TODO")) + assert result.passed is False + + +def test_completeness_error_carries_missing_and_hints() -> None: + err = tc.TaskCompletenessError( + missing=["acceptance_criteria"], + field_hints={"acceptance_criteria": "non-empty list"}, + ) + assert err.missing == ["acceptance_criteria"] + assert err.field_hints["acceptance_criteria"] == "non-empty list" + + +def test_field_hints_for_missing_fields_are_actionable() -> None: + """Each hint must mention the field and what valid input looks like.""" + result = tc.check( + tc.TASK_AT_CREATE, + _task( + description="", + acceptance_criteria=[], + nature=None, + ), + ) + for field in ("description", "acceptance_criteria", "nature"): + assert field in result.field_hints, f"no hint for {field}" + hint = result.field_hints[field] + assert len(hint) >= MIN_HINT_LEN, ( + f"hint for {field} too short to be useful: {hint!r}" + ) + + +def test_fill_team_from_assignee_resolves_dev_slug() -> None: + payload = {"assigned_to": "be-dev-1"} + result = tc.fill_team_from_assignee(payload) + assert result["team"] == "backend" + assert result["assigned_to"] == "be-dev-1" + + +def test_fill_team_from_assignee_does_not_overwrite_explicit_team() -> None: + payload = {"assigned_to": "be-dev-1", "team": "frontend"} + result = tc.fill_team_from_assignee(payload) + # Auto-fill never silently overwrites; team stays as caller passed it. + assert result["team"] == "frontend" + + +def test_fill_team_from_assignee_unknown_slug_returns_unchanged() -> None: + payload = {"assigned_to": "notreal-1"} + result = tc.fill_team_from_assignee(payload) + # Auto-fill is best-effort; unknown slug = no fill, downstream rejects. + assert "team" not in result + + +def test_fill_priority_from_parent_inherits() -> None: + payload = {} + parent = SimpleNamespace(priority=PARENT_PRIORITY_HIGH) + result = tc.fill_priority_from_parent(payload, parent) + assert result["priority"] == PARENT_PRIORITY_HIGH + assert result["__priority_inherited"] is True + + +def test_fill_priority_from_parent_does_not_overwrite_explicit() -> None: + payload = {"priority": 1} + parent = SimpleNamespace(priority=PARENT_PRIORITY_HIGH) + result = tc.fill_priority_from_parent(payload, parent) + assert result["priority"] == 1 + assert "__priority_inherited" not in result + + +def test_fill_priority_from_parent_no_parent_uses_medium_default() -> None: + payload = {} + result = tc.fill_priority_from_parent(payload, None) + assert result["priority"] == DEFAULT_PRIORITY_MEDIUM + assert result["__priority_inherited"] is True + + +def test_fill_parent_from_active_task_sets_id() -> None: + payload = {} + result = tc.fill_parent_from_active_task(payload, "task-id-123") + assert result["parent_task_id"] == "task-id-123" + + +def test_fill_parent_from_active_task_does_not_overwrite_explicit() -> None: + payload = {"parent_task_id": "explicit-id"} + result = tc.fill_parent_from_active_task(payload, "active-id") + assert result["parent_task_id"] == "explicit-id" diff --git a/tests/foundation/test_tracing.py b/tests/foundation/test_tracing.py new file mode 100644 index 00000000..13bb54e9 --- /dev/null +++ b/tests/foundation/test_tracing.py @@ -0,0 +1,187 @@ +"""Tier 1 — tracing Requirement enum + check_requirements scaffolding.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from roboco.foundation.policy import tracing + + +def test_requirement_enum_has_canonical_values() -> None: + """Required-set vocabulary mirrors the pre-Phase-2 tracing_gate.py + PLUS the new pre-gateway parity additions.""" + expected = { + "plan", + "commits>=1", + "pr_open", + "progress>=1", + "journal:reflect", + "journal:decision", + "journal:learning", + "journal:struggle", + "journal:note_at_claim", + "journal:decision_at_claim", + "journal:during_work>=1", + "acceptance_criteria_addressed", + "qa_notes>=min", + "qa_evidence_inspected", + "docs_notes>=min", + "docs_files_non_empty", + "self_verified", + "notes>=min", + "subtasks_terminal", + } + actual = {r.value for r in tracing.Requirement} + assert actual == expected, f"Requirement drift: {actual ^ expected}" + + +def test_gate_context_has_journal_presence_flags() -> None: + ctx = tracing.GateContext() + assert ctx.journal_reflect_present is False + assert ctx.journal_decision_present is False + assert ctx.journal_learning_present is False + assert ctx.journal_struggle_present is False + assert ctx.journal_note_at_claim_present is False + assert ctx.journal_during_work_count == 0 + + +def test_gate_result_has_passed_and_missing() -> None: + result = tracing.GateResult(passed=True) + assert result.passed is True + assert result.missing == [] + + +def test_check_requirements_passes_when_all_satisfied() -> None: + task = SimpleNamespace( + plan={"x": 1}, + commits=[{"sha": "abc"}], + pr_number=42, + progress_updates=[{"message": "x"}], + acceptance_criteria=[], + acceptance_criteria_status=[], + ) + ctx = tracing.GateContext(journal_reflect_present=True) + result = tracing.check_requirements( + task=task, + requirements=[ + tracing.Requirement.PLAN, + tracing.Requirement.COMMITS_AT_LEAST_ONE, + tracing.Requirement.PR_OPEN, + tracing.Requirement.JOURNAL_REFLECT, + ], + ctx=ctx, + ) + assert result.passed is True + + +def test_check_requirements_returns_missing_keys_on_failure() -> None: + task = SimpleNamespace( + plan=None, + commits=[], + pr_number=None, + progress_updates=[], + acceptance_criteria=[], + acceptance_criteria_status=[], + ) + result = tracing.check_requirements( + task=task, + requirements=[ + tracing.Requirement.PLAN, + tracing.Requirement.COMMITS_AT_LEAST_ONE, + tracing.Requirement.PR_OPEN, + tracing.Requirement.JOURNAL_REFLECT, + ], + ) + assert result.passed is False + assert "plan" in result.missing + assert "commits>=1" in result.missing + assert "pr_open" in result.missing + assert "journal:reflect" in result.missing + + +def test_acceptance_criteria_check_treats_reflect_note_as_addressing_artifact() -> None: + """Spec §9 item 1: reflect-note clears the criteria gate.""" + task = SimpleNamespace( + acceptance_criteria=["AC1", "AC2"], + acceptance_criteria_status=[], + ) + ctx = tracing.GateContext(journal_reflect_present=True) + result = tracing.check_requirements( + task=task, + requirements=[tracing.Requirement.ACCEPTANCE_CRITERIA_ADDRESSED], + ctx=ctx, + ) + assert result.passed is True + + +def test_during_work_count_satisfies_requirement() -> None: + task = SimpleNamespace() + ctx = tracing.GateContext(journal_during_work_count=1) + result = tracing.check_requirements( + task=task, + requirements=[tracing.Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE], + ctx=ctx, + ) + assert result.passed is True + + +def test_verb_requirements_covers_pm_decision_chain() -> None: + """The 6 inline journal:decision callsites' verbs all require it.""" + for verb in ( + "submit_up", + "complete", + "unblock", + "escalate_up", + "escalate_to_ceo", + "delegate", + ): + reqs = tracing.requirements_for(verb) + assert tracing.Requirement.JOURNAL_DECISION in reqs, ( + f"{verb} should require journal:decision per spec §11" + ) + + +def test_verb_requirements_covers_dev_completion_chain() -> None: + reqs = tracing.requirements_for("i_am_done") + assert tracing.Requirement.COMMITS_AT_LEAST_ONE in reqs + assert tracing.Requirement.PR_OPEN in reqs + assert tracing.Requirement.PROGRESS_AT_LEAST_ONE in reqs + assert tracing.Requirement.JOURNAL_REFLECT in reqs + assert tracing.Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE in reqs + assert tracing.Requirement.ACCEPTANCE_CRITERIA_ADDRESSED in reqs + + +def test_verb_requirements_includes_pre_gateway_parity_at_claim() -> None: + assert tracing.Requirement.JOURNAL_NOTE_AT_CLAIM in tracing.requirements_for( + "i_will_work_on" + ) + assert tracing.Requirement.JOURNAL_DECISION_AT_CLAIM in tracing.requirements_for( + "i_will_plan" + ) + + +def test_pm_complete_requires_both_decision_and_reflect() -> None: + """Pre-gateway parity P4: PMs wrote both before complete.""" + reqs = tracing.requirements_for("complete") + assert tracing.Requirement.JOURNAL_DECISION in reqs + assert tracing.Requirement.JOURNAL_REFLECT in reqs + + +def test_qa_pass_review_requires_learning() -> None: + reqs = tracing.requirements_for("pass_review") + assert tracing.Requirement.QA_NOTES_MIN_CHARS in reqs + assert tracing.Requirement.QA_EVIDENCE_INSPECTED in reqs + assert tracing.Requirement.JOURNAL_LEARNING in reqs + + +def test_i_am_blocked_requires_struggle_journal() -> None: + """Lifts JOURNAL_STRUGGLE out of dangling-enum status.""" + assert tracing.Requirement.JOURNAL_STRUGGLE in tracing.requirements_for( + "i_am_blocked" + ) + + +def test_requirements_for_unknown_verb_raises_key_error() -> None: + with pytest.raises(KeyError): + tracing.requirements_for("not_a_real_verb") diff --git a/tests/foundation/test_tracing_verb_parity.py b/tests/foundation/test_tracing_verb_parity.py new file mode 100644 index 00000000..b06ec0bb --- /dev/null +++ b/tests/foundation/test_tracing_verb_parity.py @@ -0,0 +1,27 @@ +"""Every gateway intent verb is either in VERB_REQUIREMENTS or VERBS_WITHOUT_TRACING.""" + +from __future__ import annotations + +from roboco.foundation.policy import lifecycle as spec +from roboco.foundation.policy import tracing + + +def test_every_intent_verb_has_a_tracing_decision() -> None: + intent_verbs = set(spec._INTENT_VERBS.keys()) + in_table = set(tracing.VERB_REQUIREMENTS) + in_waived = set(tracing.VERBS_WITHOUT_TRACING) + + uncovered = intent_verbs - in_table - in_waived + assert uncovered == set(), ( + f"verbs in lifecycle.spec without tracing decision: {uncovered}" + ) + + +def test_every_requirement_is_used_by_at_least_one_verb() -> None: + """No dangling enum values.""" + used: set[tracing.Requirement] = set() + for reqs in tracing.VERB_REQUIREMENTS.values(): + used.update(reqs) + + unused = set(tracing.Requirement) - used + assert unused == set(), f"Requirement values referenced by no verb: {unused}" diff --git a/tests/foundation/test_validate.py b/tests/foundation/test_validate.py new file mode 100644 index 00000000..ab60fc2c --- /dev/null +++ b/tests/foundation/test_validate.py @@ -0,0 +1,85 @@ +"""Tier 1 — validator self-tests. + +Each test temporarily corrupts a foundation table, runs the validator, +asserts it raises a clear error, then restores the table. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +import pytest +from roboco.foundation import _validate, identity + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@contextmanager +def _patched_agents(patch: dict[str, identity.AgentRow]) -> Iterator[None]: + original = dict(identity.AGENTS) + identity.AGENTS.update(patch) + try: + yield + finally: + identity.AGENTS.clear() + identity.AGENTS.update(original) + + +def test_validators_pass_on_pristine_state() -> None: + """All validators pass against the real AGENTS table.""" + _validate.run_all() # no exception + + +def test_duplicate_uuid_fails() -> None: + """Two slugs sharing a UUID is rejected.""" + duplicate = identity.AgentRow( + slug="rogue-1", + role=identity.Role.DEVELOPER, + team=identity.Team.BACKEND, + uuid=identity.AGENTS["be-dev-1"].uuid, # same UUID as be-dev-1 + ) + with _patched_agents({"rogue-1": duplicate}): + with pytest.raises(_validate.IdentityValidationError) as exc_info: + _validate.run_all() + assert "duplicate UUID" in str(exc_info.value) + + +def test_role_without_agent_fails_except_system() -> None: + """Every Role except SYSTEM must have at least one agent.""" + # All real roles have agents in pristine state, so this passes. + _validate.run_all() + # Removing all developers should fail: + no_devs = { + slug: row + for slug, row in identity.AGENTS.items() + if row.role != identity.Role.DEVELOPER + } + original = dict(identity.AGENTS) + identity.AGENTS.clear() + identity.AGENTS.update(no_devs) + try: + with pytest.raises(_validate.IdentityValidationError) as exc_info: + _validate.run_all() + assert "developer" in str(exc_info.value).lower() + finally: + identity.AGENTS.clear() + identity.AGENTS.update(original) + + +def test_role_level_covers_all_roles() -> None: + """ROLE_LEVEL must cover every Role.""" + _validate.run_all() + # Removing one role's level entry would fail: + original_entry = identity.ROLE_LEVEL.pop(identity.Role.DEVELOPER) + try: + with pytest.raises(_validate.IdentityValidationError): + _validate.run_all() + finally: + identity.ROLE_LEVEL[identity.Role.DEVELOPER] = original_entry + + +def test_pm_roles_consistent_with_agents() -> None: + """PM_ROLES are populated by at least one agent each (CELL_PM and MAIN_PM).""" + _validate.run_all() diff --git a/tests/integration/test_a2a_priority_tristate.py b/tests/integration/test_a2a_priority_tristate.py new file mode 100644 index 00000000..550b6c27 --- /dev/null +++ b/tests/integration/test_a2a_priority_tristate.py @@ -0,0 +1,392 @@ +"""A2A urgency must round-trip as the full Priority tristate. + +Pre-Phase-3 the A2A path collapsed `priority` at the request boundary +into a boolean `urgent`, then mapped that boolean back to +NotificationPriority.URGENT / NORMAL — so the middle tier +NotificationPriority.HIGH was unreachable through the A2A code path. + +After Task 9 the tristate (NORMAL/HIGH/URGENT) survives end-to-end: +the NotificationTable row inserted by `send_a2a_notification` carries +the priority the caller asked for. + +These tests pin that contract on both layers: + + * unit — `NotificationService.send_a2a_notification` accepts a + `Priority` in its `a2a_context` and writes that exact enum value + to the inserted row. + * service — `A2AService.create_a2a_notification` parses + `metadata["priority"]` (preferring the tristate value) and passes + a `Priority` through to NotificationService. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio +from roboco.db.tables import AgentTable, ProjectTable, TaskTable +from roboco.foundation.policy.communications import Priority +from roboco.models import AgentRole, AgentStatus, Team +from roboco.models.a2a import A2AMessage, SendMessageRequest, TextPart +from roboco.models.base import NotificationPriority, TaskNature, TaskStatus, TaskType +from roboco.services.a2a import A2AService +from roboco.services.notification import NotificationService + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from sqlalchemy.ext.asyncio import AsyncSession + + +# --------------------------------------------------------------------------- +# Foundation sanity — Priority IS the tristate, not a boolean. +# --------------------------------------------------------------------------- + + +def test_priority_has_three_distinct_values() -> None: + """If this fails Phase 3 Task 1 regressed.""" + assert Priority.NORMAL != Priority.HIGH + assert Priority.HIGH != Priority.URGENT + assert Priority.NORMAL != Priority.URGENT + # Same enum object as the DB column type. + assert Priority is NotificationPriority + + +# --------------------------------------------------------------------------- +# NotificationService.send_a2a_notification — DB row carries tristate +# --------------------------------------------------------------------------- + + +class _FakeDb: + """Records inserted notification rows so we can assert on .priority.""" + + def __init__(self, *, agent_uuid: UUID) -> None: + self.added: list = [] + self._agent_uuid = agent_uuid + + def add(self, obj) -> None: + self.added.append(obj) + obj.id = uuid4() + + async def flush(self) -> None: + return None + + async def commit(self) -> None: + return None + + async def execute(self, *_args, **_kwargs): + result = MagicMock() + agent = MagicMock() + agent.id = self._agent_uuid + agent.slug = "test-agent" + result.scalar_one_or_none.return_value = agent + result.scalars.return_value.all.return_value = [] + return result + + +@asynccontextmanager +async def _fake_ctx(db: _FakeDb): + yield db + + +class _PatchDbContext: + def __init__(self, db: _FakeDb) -> None: + delivery_mock = MagicMock() + delivery_mock.deliver = AsyncMock(return_value=None) + self._patches = [ + patch( + "roboco.services.notification.get_db_context", + lambda: _fake_ctx(db), + ), + patch( + "roboco.services.notification_delivery." + "get_notification_delivery_service", + lambda _db: delivery_mock, + ), + ] + + def __enter__(self) -> None: + for p in self._patches: + p.start() + + def __exit__(self, *_args) -> None: + for p in self._patches: + p.stop() + + +@pytest.mark.asyncio +async def test_send_a2a_notification_high_priority_writes_high() -> None: + """priority=Priority.HIGH ends up at NotificationTable.priority=HIGH. + + This is the heart of Task 9: pre-fix, HIGH was unreachable via this + method because the contract was `urgent: bool`. + """ + svc = NotificationService() + db = _FakeDb(agent_uuid=uuid4()) + with _PatchDbContext(db): + await svc.send_a2a_notification( + task_id="t1", + a2a_context={ + "from_agent": "be-dev-1", + "to_agent": "fe-dev-1", + "skill": "react", + "message": "hi", + "priority": Priority.HIGH, + }, + ) + assert db.added, "Notification row should have been inserted." + row = db.added[0] + assert row.priority == NotificationPriority.HIGH + # HIGH gets NO cosmetic [URGENT] prefix — that label is urgent-only. + assert "[URGENT]" not in row.subject + assert "[URGENT]" not in row.body + + +@pytest.mark.asyncio +async def test_send_a2a_notification_normal_priority_writes_normal() -> None: + svc = NotificationService() + db = _FakeDb(agent_uuid=uuid4()) + with _PatchDbContext(db): + await svc.send_a2a_notification( + task_id="t1", + a2a_context={ + "from_agent": "be-dev-1", + "to_agent": "fe-dev-1", + "skill": "react", + "message": "hi", + "priority": Priority.NORMAL, + }, + ) + row = db.added[0] + assert row.priority == NotificationPriority.NORMAL + assert "[URGENT]" not in row.subject + + +@pytest.mark.asyncio +async def test_send_a2a_notification_urgent_preserves_prefix() -> None: + """URGENT still gets the cosmetic [URGENT] prefix in subject + body.""" + svc = NotificationService() + db = _FakeDb(agent_uuid=uuid4()) + with _PatchDbContext(db): + await svc.send_a2a_notification( + task_id="t1", + a2a_context={ + "from_agent": "be-dev-1", + "to_agent": "fe-dev-1", + "skill": "react", + "message": "hi", + "priority": Priority.URGENT, + }, + ) + row = db.added[0] + assert row.priority == NotificationPriority.URGENT + assert "[URGENT]" in row.subject + assert "[URGENT]" in row.body + + +# --------------------------------------------------------------------------- +# A2AService.create_a2a_notification — request parses + forwards Priority +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def a2a_setup( + db_session: AsyncSession, +) -> AsyncIterator[dict]: + # Same seeded slugs the existing a2a tests use — A2A policy + # rejects unknown roles. + dev = AgentTable( + id=uuid4(), + name="Dev", + slug="be-dev-1", + role=AgentRole.DEVELOPER, + team=Team.BACKEND, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="dev", + capabilities=[], + permissions={}, + metrics={}, + ) + qa = AgentTable( + id=uuid4(), + name="QA", + slug="be-qa", + role=AgentRole.QA, + team=Team.BACKEND, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="qa", + capabilities=[], + permissions={}, + metrics={}, + ) + db_session.add_all([dev, qa]) + await db_session.flush() + project = ProjectTable( + id=uuid4(), + name="A-Proj", + slug=f"a-proj-{uuid4().hex[:8]}", + git_url="https://example.com/r.git", + assigned_cell=Team.BACKEND, + created_by=dev.id, + ) + db_session.add(project) + await db_session.flush() + task = TaskTable( + id=uuid4(), + title="t", + description="d", + acceptance_criteria=["ac"], + status=TaskStatus.PENDING, + priority=2, + task_type=TaskType.CODE, + nature=TaskNature.TECHNICAL, + project_id=project.id, + created_by=dev.id, + team=Team.BACKEND, + ) + db_session.add(task) + await db_session.flush() + yield { + "svc": A2AService(db_session), + "task_id": task.id, + } + + +@pytest.mark.asyncio +async def test_create_a2a_notification_metadata_priority_high_propagates( + a2a_setup: dict, +) -> None: + """metadata={"priority": "high"} → NotificationService gets Priority.HIGH.""" + svc = a2a_setup["svc"] + task_id = str(a2a_setup["task_id"]) + msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) + req = SendMessageRequest( + message=msg, + metadata={ + "from_agent": "be-dev-1", + "target_agent": "be-qa", + "priority": "high", + }, + ) + mock_ns = AsyncMock() + mock_ns.send_a2a_notification = AsyncMock(return_value=None) + with patch( + "roboco.services.notification.NotificationService", + return_value=mock_ns, + ): + await svc.create_a2a_notification(req) + mock_ns.send_a2a_notification.assert_awaited_once() + kwargs = mock_ns.send_a2a_notification.await_args.kwargs + a2a_context = kwargs["a2a_context"] + assert a2a_context["priority"] == Priority.HIGH + + +@pytest.mark.asyncio +async def test_create_a2a_notification_metadata_priority_urgent_propagates( + a2a_setup: dict, +) -> None: + svc = a2a_setup["svc"] + task_id = str(a2a_setup["task_id"]) + msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) + req = SendMessageRequest( + message=msg, + metadata={ + "from_agent": "be-dev-1", + "target_agent": "be-qa", + "priority": "urgent", + }, + ) + mock_ns = AsyncMock() + mock_ns.send_a2a_notification = AsyncMock(return_value=None) + with patch( + "roboco.services.notification.NotificationService", + return_value=mock_ns, + ): + await svc.create_a2a_notification(req) + a2a_context = mock_ns.send_a2a_notification.await_args.kwargs["a2a_context"] + assert a2a_context["priority"] == Priority.URGENT + + +@pytest.mark.asyncio +async def test_create_a2a_notification_default_priority_is_normal( + a2a_setup: dict, +) -> None: + """No metadata.priority + no configuration.urgent → Priority.NORMAL.""" + svc = a2a_setup["svc"] + task_id = str(a2a_setup["task_id"]) + msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) + req = SendMessageRequest( + message=msg, + metadata={"from_agent": "be-dev-1", "target_agent": "be-qa"}, + ) + mock_ns = AsyncMock() + mock_ns.send_a2a_notification = AsyncMock(return_value=None) + with patch( + "roboco.services.notification.NotificationService", + return_value=mock_ns, + ): + await svc.create_a2a_notification(req) + a2a_context = mock_ns.send_a2a_notification.await_args.kwargs["a2a_context"] + assert a2a_context["priority"] == Priority.NORMAL + + +@pytest.mark.asyncio +async def test_create_a2a_notification_legacy_urgent_bool_maps_to_urgent( + a2a_setup: dict, +) -> None: + """Backcompat: callers that still send `urgent: True` (e.g. agent_sdk + fallback at server.py:258) are honored as Priority.URGENT until that + path is refactored in a later task. priority key wins if both set.""" + svc = a2a_setup["svc"] + task_id = str(a2a_setup["task_id"]) + msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) + req = SendMessageRequest( + message=msg, + metadata={ + "from_agent": "be-dev-1", + "target_agent": "be-qa", + "urgent": True, + }, + ) + mock_ns = AsyncMock() + mock_ns.send_a2a_notification = AsyncMock(return_value=None) + with patch( + "roboco.services.notification.NotificationService", + return_value=mock_ns, + ): + await svc.create_a2a_notification(req) + a2a_context = mock_ns.send_a2a_notification.await_args.kwargs["a2a_context"] + assert a2a_context["priority"] == Priority.URGENT + + +@pytest.mark.asyncio +async def test_create_a2a_notification_unknown_priority_falls_back_to_normal( + a2a_setup: dict, +) -> None: + """Garbage priority string → NORMAL, not a crash.""" + svc = a2a_setup["svc"] + task_id = str(a2a_setup["task_id"]) + msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id) + req = SendMessageRequest( + message=msg, + metadata={ + "from_agent": "be-dev-1", + "target_agent": "be-qa", + "priority": "nuclear", + }, + ) + mock_ns = AsyncMock() + mock_ns.send_a2a_notification = AsyncMock(return_value=None) + with patch( + "roboco.services.notification.NotificationService", + return_value=mock_ns, + ): + await svc.create_a2a_notification(req) + a2a_context = mock_ns.send_a2a_notification.await_args.kwargs["a2a_context"] + assert a2a_context["priority"] == Priority.NORMAL diff --git a/tests/integration/test_foundation_phase1_smoke.py b/tests/integration/test_foundation_phase1_smoke.py new file mode 100644 index 00000000..43c6972c --- /dev/null +++ b/tests/integration/test_foundation_phase1_smoke.py @@ -0,0 +1,150 @@ +"""Foundation Phase 1 smoke gate. + +End-to-end: a delegate call that would have produced a skeleton task +pre-Phase-1 must now return Envelope.incomplete_input with a populated +field_hints map. No silent fallback; no ``["completed and reviewed by +assignee"]`` ever lands in the DB. +""" + +from __future__ import annotations + +import subprocess +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, +) + +# Lower bound for a useful field hint. The canonical +# ``_HINT_ACCEPTANCE_CRITERIA`` text is ~200 chars; anything under this +# would mean the hint string was truncated or replaced with a stub. +_MIN_FIELD_HINT_LEN = 30 + + +def _make_deps(**overrides: Any) -> ChoreographerDeps: + """Build a ChoreographerDeps with AsyncMock services + empty evidence repo. + + Mirrors ``tests/unit/gateway/test_delegate_incomplete_input.py``: every + evidence_repo lookup the briefing assembler reaches for must return + ``[]`` so the briefing build does not raise on a coroutine result. + """ + 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 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 ChoreographerDeps(**base) + + +@pytest.mark.asyncio +async def test_skeleton_task_path_returns_incomplete_input() -> None: + """The 2026-05-10 smoke run produced subtasks with empty + acceptance_criteria via the gateway. After Phase 1, that exact + sequence must produce incomplete_input rejections instead. + """ + pm_id = uuid4() + parent = MagicMock( + id=uuid4(), + project_id=uuid4(), + status="in_progress", + assigned_to=pm_id, + priority=2, + ) + task_svc = AsyncMock() + task_svc.get.return_value = parent + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role="cell_pm", team="backend", slug="be-pm" + ) + task_svc.get_subtasks.return_value = [] + deps = _make_deps(task=task_svc) + c = Choreographer(deps) + + # The exact shape from the failing smoke run: acceptance_criteria + # missing, optional fields not provided. + env = await c.delegate( + pm_id, + parent.id, + DelegateInputs( + title="Branch naming smoke test", + description=( + "Verify branch creation follows the feature/team/task convention." + ), + assigned_to="be-dev-1", + team="backend", + task_type="code", + nature="technical", + estimated_complexity="medium", + acceptance_criteria=None, + ), + ) + body = env.as_dict() + assert body["error"] == "incomplete_input", ( + f"expected incomplete_input, got: {body}" + ) + assert "acceptance_criteria" in body["missing"] + # The agent learns from field_hints what to fill: + assert "acceptance_criteria" in body["field_hints"] + assert len(body["field_hints"]["acceptance_criteria"]) > _MIN_FIELD_HINT_LEN + # Skeleton task never reached the DB. + task_svc.create_subtask.assert_not_awaited() + + +def test_no_silent_fallback_phrase_in_repo() -> None: + """Production code must not contain the placeholder string outside of + documented locations. + + Allowed appearances after Tasks 12 + 18 + 20: + - ``roboco/foundation/policy/task_completeness.py`` — the DENYLIST + entry plus the ``_HINT_ACCEPTANCE_CRITERIA`` text that names the + phrase as a known evasion. + - ``roboco/api/routes/tasks.py`` — comment in the POST /tasks handler + explaining why the route runs ``task_completeness.check`` (Task 20). + - ``roboco/services/task.py`` — docstring on ``create_subtask`` + documenting the Task 18 deletion of the silent fallback. + + Any other appearance is a real Phase 1 gap. + """ + proc = subprocess.run( + # ``-I`` skips binary files so stray ``__pycache__/*.pyc`` matches + # (left over from a previous run) don't contaminate the grep. + ["grep", "-rnI", "completed and reviewed by assignee", "roboco/"], + capture_output=True, + text=True, + check=False, + ) + allowed_files = ( + "roboco/foundation/policy/task_completeness.py", + "roboco/api/routes/tasks.py", + "roboco/services/task.py", + ) + suspicious: list[str] = [] + for line in proc.stdout.splitlines(): + if not line: + continue + if any(line.startswith(f"{path}:") for path in allowed_files): + continue + suspicious.append(line) + assert suspicious == [], ( + f"silent-fallback phrase still present in production code: {suspicious}" + ) diff --git a/tests/integration/test_foundation_phase2_smoke.py b/tests/integration/test_foundation_phase2_smoke.py new file mode 100644 index 00000000..f451b3b4 --- /dev/null +++ b/tests/integration/test_foundation_phase2_smoke.py @@ -0,0 +1,87 @@ +"""Foundation Phase 2 smoke gate — every journal:X check goes through tracing.""" + +from __future__ import annotations + +import ast +import importlib +from pathlib import Path + +import pytest +from roboco.foundation.policy import lifecycle as spec +from roboco.foundation.policy import tracing + +_GATEWAY_DIR = Path(__file__).resolve().parents[2] / "roboco" / "services" / "gateway" + + +def _enclosing_function(tree: ast.AST, lineno: int) -> str | None: + """Return the name of the (async) function whose body contains ``lineno``.""" + candidate: str | None = None + candidate_start = -1 + for node in ast.walk(tree): + if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)): + start = node.lineno + end = getattr(node, "end_lineno", None) or start + if start <= lineno <= end and start > candidate_start: + candidate = node.name + candidate_start = start + return candidate + + +def test_no_inline_has_decision_for_task_remains_in_choreographer(): + """All journal:decision checks must use tracing.check_requirements via the + unified helpers (_check_pm_decision_required, _check_complete_gates, + _check_submit_up_gates, _check_tracing_gates, _check_claim_journal_at_claim, + _post_claim_journal_gate).""" + allowed_helpers = { + "_check_pm_decision_required", + "_check_complete_gates", + "_check_submit_up_gates", + "_check_tracing_gates", + "_check_claim_journal_at_claim", + "_post_claim_journal_gate", + } + suspicious: list[str] = [] + for py_path in _GATEWAY_DIR.rglob("*.py"): + source = py_path.read_text() + if "has_decision_for_task" not in source: + continue + tree = ast.parse(source, filename=str(py_path)) + for lineno, line in enumerate(source.splitlines(), start=1): + if "has_decision_for_task" not in line: + continue + enclosing = _enclosing_function(tree, lineno) + # Helper definition / docstring references don't count; only + # call-site usages matter, but if the line is inside a helper + # whose body is allowed, we accept it. + if enclosing in allowed_helpers: + continue + # Allow references within docstrings (no executable impact). + stripped = line.strip() + if stripped.startswith("#") or stripped.startswith('"'): + continue + suspicious.append(f"{py_path}:{lineno}:{line.strip()}") + assert suspicious == [], f"inline has_decision_for_task remains: {suspicious}" + + +def test_tracing_gate_module_removed(): + with pytest.raises(ModuleNotFoundError): + importlib.import_module("roboco.services.gateway.tracing_gate") + + +def test_every_intent_verb_has_tracing_decision(): + """Mirror of the foundation parity test, as a smoke-gate.""" + intent_verbs = set(spec._INTENT_VERBS.keys()) + in_table = set(tracing.VERB_REQUIREMENTS) + in_waived = set(tracing.VERBS_WITHOUT_TRACING) + assert intent_verbs - in_table - in_waived == set(), ( + f"verbs in lifecycle.spec without tracing decision: " + f"{intent_verbs - in_table - in_waived}" + ) + + +def test_no_dangling_requirements(): + """Every Requirement value is referenced by at least one verb.""" + used = set() + for reqs in tracing.VERB_REQUIREMENTS.values(): + used.update(reqs) + assert set(tracing.Requirement) - used == set() diff --git a/tests/integration/test_foundation_phase3_smoke.py b/tests/integration/test_foundation_phase3_smoke.py new file mode 100644 index 00000000..b26bc0e9 --- /dev/null +++ b/tests/integration/test_foundation_phase3_smoke.py @@ -0,0 +1,105 @@ +"""Foundation Phase 3 smoke gate — communications + agent_loop canonicalization.""" + +from __future__ import annotations + +import importlib +import inspect + +import pytest +from roboco import agents_config +from roboco.agents_config import CHANNEL_ACCESS +from roboco.foundation import identity +from roboco.foundation.policy import communications +from roboco.foundation.policy.agent_loop import ( + DEFAULT_BUDGET, + VERB_RETRY_LIMITS, +) +from roboco.foundation.policy.communications import ( + ACK_REQUIRED_BY_TYPE, + NOTIFY_SENDER_ROLES, + Priority, +) +from roboco.models.base import NotificationType +from roboco.seeds.initial_data import DEFAULT_CHANNELS +from roboco.services.gateway import content_actions +from roboco.services.gateway.envelope import Envelope + +_EXPECTED_VERB_RETRY_LIMIT = 3 + + +def test_notification_perms_module_removed(): + """services/enforcement/notification_perms.py is gone.""" + with pytest.raises(ModuleNotFoundError): + importlib.import_module("roboco.enforcement.notification_perms") + + +def test_agents_config_notification_permissions_removed(): + """The contradictory NOTIFICATION_PERMISSIONS dict is gone.""" + assert not hasattr(agents_config, "NOTIFICATION_PERMISSIONS") + + +def test_channel_access_derives_from_foundation(): + """agents_config.CHANNEL_ACCESS keys match foundation.CHANNELS exactly.""" + assert set(CHANNEL_ACCESS.keys()) == set(communications.CHANNELS.keys()) + + +def test_seed_default_channels_derive_from_foundation(): + """seeds.DEFAULT_CHANNELS slugs match foundation.CHANNELS.""" + seed_slugs = {ch["slug"] for ch in DEFAULT_CHANNELS} + foundation_slugs = set(communications.CHANNELS.keys()) + assert seed_slugs == foundation_slugs + + +def test_notify_sender_roles_includes_ceo_excludes_auditor(): + """Spec §5.5 contradiction closed.""" + assert identity.Role.CEO in NOTIFY_SENDER_ROLES + assert identity.Role.AUDITOR not in NOTIFY_SENDER_ROLES + + +def test_ack_required_table_covers_every_notification_type(): + """Spec §5.5 ACK_REQUIRED_BY_TYPE covers the full enum.""" + for nt in NotificationType: + assert nt in ACK_REQUIRED_BY_TYPE + + +def test_a2a_priority_high_reachable(): + """A2A urgency tristate end-to-end (was reduced to boolean pre-Phase-3).""" + # Confirm the foundation enum has all three values. + values = {p.value for p in Priority} + assert "normal" in values + assert "high" in values + assert "urgent" in values + + +def test_loop_action_default_is_halt(): + """Spec §5.7: BudgetPolicy.loop_action default is 'halt' (was 'warn').""" + assert DEFAULT_BUDGET.loop_action == "halt" + + +def test_verb_retry_limits_cover_critical_handoff_verbs(): + """Spec §5.7: per-verb circuit breaker has caps for the handoff verbs.""" + for verb in ("i_am_done", "complete", "submit_up", "delegate"): + assert verb in VERB_RETRY_LIMITS + assert VERB_RETRY_LIMITS[verb] == _EXPECTED_VERB_RETRY_LIMIT + + +def test_envelope_circuit_open_kind_distinct_from_tracing_gap(): + """Spec §5.7: circuit_open envelope is its own kind.""" + env_co = Envelope.circuit_open( + verb="i_am_done", attempts=4, window_seconds=60, remediate="x" + ) + env_tg = Envelope.tracing_gap(missing=["x"], remediate="y") + assert env_co.as_dict()["error"] == "circuit_open" + assert env_tg.as_dict()["error"] == "tracing_gap" + assert env_co.as_dict()["error"] != env_tg.as_dict()["error"] + + +def test_auditor_silent_runtime_guard_in_say_dm(): + """Spec §5.5: auditor say/dm refused at runtime (defense in depth).""" + # The actual guard test lives in tests/unit/gateway/test_auditor_silent_guard.py. + # Smoke gate verifies the guard exists by checking the source for the + # specific role-check pattern. + say_source = inspect.getsource(content_actions.ContentActions.say) + dm_source = inspect.getsource(content_actions.ContentActions.dm) + assert "auditor" in say_source.lower(), "say() missing auditor runtime guard" + assert "auditor" in dm_source.lower(), "dm() missing auditor runtime guard" diff --git a/tests/integration/test_foundation_phase4_smoke.py b/tests/integration/test_foundation_phase4_smoke.py new file mode 100644 index 00000000..fff0c049 --- /dev/null +++ b/tests/integration/test_foundation_phase4_smoke.py @@ -0,0 +1,85 @@ +"""Foundation Phase 4 smoke gate — final package layout.""" + +from __future__ import annotations + +import importlib +import inspect +import subprocess +from pathlib import Path + +import pytest +from roboco.api import deps as api_deps +from roboco.api.routes.v2 import _role_dep as v2_role_dep + + +def test_lifecycle_module_lives_in_foundation(): + """Canonical import path is foundation.policy.lifecycle.""" + lifecycle = importlib.import_module("roboco.foundation.policy.lifecycle") + assert hasattr(lifecycle, "Role") + assert hasattr(lifecycle, "Status") + assert hasattr(lifecycle, "_INTENT_VERBS") + + +def test_legacy_lifecycle_package_removed(): + """Legacy roboco.lifecycle package is gone.""" + with pytest.raises(ModuleNotFoundError): + importlib.import_module("roboco.lifecycle") + + +def test_legacy_lifecycle_spec_module_removed(): + with pytest.raises(ModuleNotFoundError): + importlib.import_module("roboco.lifecycle.spec") + + +def test_foundation_policy_complete(): + """All 6 policy domains exist in foundation.""" + for mod in ( + "lifecycle", + "tracing", + "journaling", + "task_completeness", + "communications", + "agent_loop", + ): + importlib.import_module(f"roboco.foundation.policy.{mod}") + + +def test_no_lifecycle_imports_in_production(): + """Production code (roboco/) imports from foundation directly, no legacy paths.""" + proc = subprocess.run( + [ + "grep", + "-rn", + "from roboco.lifecycle\\|import roboco.lifecycle", + "roboco/", + "--include=*.py", + ], + capture_output=True, + text=True, + check=False, + ) + suspicious = [] + for line in proc.stdout.splitlines(): + if not line: + continue + # Allow docstring/comment mentions (the line contains 'lifecycle' but + # not as an actual import statement). A bare-bones filter: + suspicious.append(line) + assert suspicious == [], f"legacy lifecycle imports remain: {suspicious}" + + +def test_route_guard_role_sets_derive_from_foundation(): + """api/deps.py + v2/_role_dep.py use foundation Role-set composition.""" + deps_src = inspect.getsource(api_deps) + role_dep_src = inspect.getsource(v2_role_dep) + assert ( + "from roboco.foundation.identity" in deps_src + or "from roboco.foundation import" in deps_src + ) + assert "Role." in role_dep_src # uses Role enum members, not raw strings + + +def test_make_foundation_check_target_exists(): + """Drift gate target is present in Makefile.""" + makefile = Path("Makefile").read_text(encoding="utf-8") + assert "foundation-check:" in makefile diff --git a/tests/integration/test_full_lifecycle_real_db.py b/tests/integration/test_full_lifecycle_real_db.py index 05bfdff3..661f7001 100644 --- a/tests/integration/test_full_lifecycle_real_db.py +++ b/tests/integration/test_full_lifecycle_real_db.py @@ -148,6 +148,7 @@ def _mock_journal_with_reflect() -> Any: 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 return journal diff --git a/tests/integration/test_lifecycle_real_db.py b/tests/integration/test_lifecycle_real_db.py new file mode 100644 index 00000000..5eab4494 --- /dev/null +++ b/tests/integration/test_lifecycle_real_db.py @@ -0,0 +1,819 @@ +"""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 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 + +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 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.""" + 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 + 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="add the route") + 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:`` 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. + + With no Main PM seeded, ``_handle_cell_pm_escalation`` short-circuits + and the task transitions straight to COMPLETED instead of being + handed up to the Main PM. This is the "simple task" path the plan + calls out — no parent task, no Main PM, no CEO escalation. + + Test isolation note: ``test_groups_routes.py`` exercises the + groups POST endpoint, which commits via ``db.commit()`` and + persists a MAIN_PM agent across sessions in the test DB. We + delete any pre-existing MAIN_PM rows at the top of this test so + the cell PM completion is the only one in play. The delete runs + inside this test's session and is unwound by the conftest's + rollback, so committed state in the shared DB is untouched. + """ + 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="implement /healthz") + 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="implement /healthz") + 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 diff --git a/tests/integration/test_post_tasks_completeness.py b/tests/integration/test_post_tasks_completeness.py new file mode 100644 index 00000000..26ab50d8 --- /dev/null +++ b/tests/integration/test_post_tasks_completeness.py @@ -0,0 +1,130 @@ +"""POST /tasks must reject under-filled tasks at the route boundary. + +Mirrors task_completeness.TASK_AT_CREATE. The route layer does a defense- +in-depth completeness check after Pydantic validation, then forwards to +the service. This test pins the route-level enforcement so that future +edits cannot regress the contract that POST /tasks runs the canonical +completeness checker. +""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from roboco.api.deps import get_agent_context, get_db +from roboco.api.routes.tasks import router as tasks_router +from roboco.db.tables import AgentTable, ProjectTable +from roboco.models import AgentRole, AgentStatus, Team +from roboco.models.permissions import AgentContext + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest_asyncio.fixture +async def post_tasks_client( + db_session: AsyncSession, +) -> AsyncIterator[dict]: + main_pm = AgentTable( + id=uuid4(), + name="MainPM", + slug=f"main-pm-{uuid4().hex[:8]}", + role=AgentRole.MAIN_PM, + team=None, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="pm", + capabilities=[], + permissions={}, + metrics={}, + ) + db_session.add(main_pm) + await db_session.flush() + project = ProjectTable( + id=uuid4(), + name="PostTasksProj", + slug=f"post-tasks-{uuid4().hex[:6]}", + git_url="https://example.com/r.git", + assigned_cell=Team.BACKEND, + created_by=main_pm.id, + ) + db_session.add(project) + await db_session.flush() + + app = FastAPI() + app.include_router(tasks_router, prefix="/api/tasks") + + async def _override_db(): + yield db_session + + async def _override_agent() -> AgentContext: + return AgentContext(agent_id=main_pm.id, role=AgentRole.MAIN_PM, team=None) + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_agent_context] = _override_agent + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield {"client": client, "project": project} + app.dependency_overrides.clear() + + +_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"} + + +@pytest.mark.asyncio +async def test_post_tasks_rejects_empty_acceptance_criteria( + post_tasks_client: dict, +) -> None: + """Schema-level rejection: TaskCreate.acceptance_criteria has min_length=1.""" + client = post_tasks_client["client"] + payload = { + "title": "Something", + "description": "A long enough description, easily over twenty chars.", + "acceptance_criteria": [], + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "team": "backend", + "project_id": str(post_tasks_client["project"].id), + } + resp = await client.post("/api/tasks", json=payload, headers=_HDR) + # Pydantic ValidationError on TaskCreate.acceptance_criteria min_length=1. + assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_post_tasks_rejects_placeholder_phrase( + post_tasks_client: dict, +) -> None: + """Route-level enforcement: denylist catches placeholders that pass Pydantic. + + The phrase 'completed and reviewed by assignee' is a denylisted legacy + silent-fallback phrase. Pydantic accepts it (non-empty list of non-empty + strings) but `task_completeness.check(TASK_AT_CREATE, payload)` rejects. + """ + client = post_tasks_client["client"] + payload = { + "title": "Something", + "description": "A long enough description, easily over twenty chars.", + "acceptance_criteria": ["completed and reviewed by assignee"], + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "team": "backend", + "project_id": str(post_tasks_client["project"].id), + } + resp = await client.post("/api/tasks", json=payload, headers=_HDR) + assert resp.status_code in ( + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNPROCESSABLE_ENTITY, + ) + assert "acceptance_criteria" in resp.text.lower() diff --git a/tests/integration/test_task_service_background.py b/tests/integration/test_task_service_background.py index 8e955e13..67998b7b 100644 --- a/tests/integration/test_task_service_background.py +++ b/tests/integration/test_task_service_background.py @@ -24,7 +24,9 @@ from roboco.db.tables import AgentTable, ProjectTable, WorkSessionTable from roboco.models import AgentRole, AgentStatus, Team from roboco.models.base import ( Complexity, + TaskNature, TaskStatus, + TaskType, ) from roboco.models.task import TaskCreateRequest from roboco.models.work_session import WorkSessionStatus @@ -85,6 +87,9 @@ def _req(setup: dict, **overrides) -> TaskCreateRequest: team=overrides.pop("team", Team.BACKEND), created_by=setup["agent_id"], project_id=setup["project_id"], + task_type=overrides.pop("task_type", TaskType.CODE), + nature=overrides.pop("nature", TaskNature.TECHNICAL), + estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM), **overrides, ) diff --git a/tests/integration/test_task_service_basics.py b/tests/integration/test_task_service_basics.py index 80c47574..9190d7d5 100644 --- a/tests/integration/test_task_service_basics.py +++ b/tests/integration/test_task_service_basics.py @@ -16,7 +16,10 @@ import pytest_asyncio from roboco.db.tables import AgentTable, ProjectTable from roboco.models import AgentRole, AgentStatus, Team from roboco.models.base import ( + Complexity, + TaskNature, TaskStatus, + TaskType, ) from roboco.models.task import TaskCreateRequest from roboco.services.task import SoftBlockInfo, TaskService @@ -73,6 +76,9 @@ def _req(setup: dict, **overrides) -> TaskCreateRequest: team=overrides.pop("team", Team.BACKEND), created_by=setup["agent_id"], project_id=setup["project_id"], + task_type=overrides.pop("task_type", TaskType.CODE), + nature=overrides.pop("nature", TaskNature.TECHNICAL), + estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM), **overrides, ) diff --git a/tests/integration/test_task_service_lifecycle_misc.py b/tests/integration/test_task_service_lifecycle_misc.py index b8280580..f111b461 100644 --- a/tests/integration/test_task_service_lifecycle_misc.py +++ b/tests/integration/test_task_service_lifecycle_misc.py @@ -26,9 +26,12 @@ from roboco.db.tables import ( from roboco.models import AgentRole, AgentStatus, Team from roboco.models.base import ( ChannelType, + Complexity, SessionStatus, SubstituteReason, + TaskNature, TaskStatus, + TaskType, ) from roboco.models.permissions import AgentContext from roboco.models.task import TaskCreateRequest @@ -93,6 +96,9 @@ def _req(setup: dict, **overrides) -> TaskCreateRequest: team=overrides.pop("team", Team.BACKEND), created_by=setup["agent_id"], project_id=setup["project_id"], + task_type=overrides.pop("task_type", TaskType.CODE), + nature=overrides.pop("nature", TaskNature.TECHNICAL), + estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM), **overrides, ) @@ -232,7 +238,7 @@ async def test_inject_proactive_context_skips_when_claim_rolled_back( async def __aenter__(self) -> Any: return self._session - async def __aexit__(self, exc_type, exc, tb) -> None: + async def __aexit__(self, exc_type, exc, _tb) -> None: return None factory_instance = _SessionFactory() @@ -278,7 +284,7 @@ async def test_inject_proactive_context_writes_when_context_nonempty( async def __aenter__(self) -> Any: return self._session - async def __aexit__(self, exc_type, exc, tb) -> None: + async def __aexit__(self, exc_type, exc, _tb) -> None: return None class _Factory: @@ -770,24 +776,29 @@ async def test_cancel_with_branch_and_work_session( @pytest.mark.asyncio -async def test_cancel_descendants_role_validation_skipped( +async def test_cancel_descendants_cascades_for_authorized_pm( task_setup: dict, db_session: AsyncSession ) -> None: - """When agent_role is not allowed for a child's status, descendant cancel - is skipped but parent still cancels. + """A `cell_pm` cancel cascades through descendants in any non-terminal state. + + Predecessor test asserted CEO-only authority over + `awaiting_ceo_approval` cancels (legacy table behavior). The + canonical spec (`roboco.foundation.policy.lifecycle`) authorizes + cancel from every non-terminal source for {CELL_PM, MAIN_PM, CEO} + uniformly, so a PM cancel now sweeps the whole subtree — including + descendants parked in `awaiting_ceo_approval`. """ svc = task_setup["svc"] parent = await svc.create(_req(task_setup)) child = await svc.create(_req(task_setup, parent_task_id=parent.id)) - # Put child in awaiting_ceo_approval — only CEO can cancel child.status = TaskStatus.AWAITING_CEO_APPROVAL await db_session.flush() out = await svc.cancel(parent.id, agent_role="cell_pm") assert out is not None refreshed_child = await svc.get(child.id) assert refreshed_child is not None - # Child stays in awaiting_ceo_approval (not cancellable by cell_pm) - assert refreshed_child.status == TaskStatus.AWAITING_CEO_APPROVAL + # Child cascades to cancelled along with the parent. + assert refreshed_child.status == TaskStatus.CANCELLED # --------------------------------------------------------------------------- diff --git a/tests/integration/test_task_service_misc.py b/tests/integration/test_task_service_misc.py index b802dc32..1cf8b55f 100644 --- a/tests/integration/test_task_service_misc.py +++ b/tests/integration/test_task_service_misc.py @@ -34,7 +34,7 @@ import pytest_asyncio from roboco.db.tables import AgentTable, ProjectTable, WorkSessionTable from roboco.enforcement import TaskLifecycleError from roboco.models import AgentRole, AgentStatus, Team -from roboco.models.base import TaskStatus +from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType from roboco.models.permissions import AgentContext from roboco.models.task import TaskCreateRequest from roboco.models.work_session import WorkSessionStatus @@ -101,6 +101,9 @@ def _req(setup: dict, **overrides) -> TaskCreateRequest: team=overrides.pop("team", Team.BACKEND), created_by=setup["agent_id"], project_id=setup["project_id"], + task_type=overrides.pop("task_type", TaskType.CODE), + nature=overrides.pop("nature", TaskNature.TECHNICAL), + estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM), **overrides, ) diff --git a/tests/integration/test_task_service_no_silent_fallback.py b/tests/integration/test_task_service_no_silent_fallback.py new file mode 100644 index 00000000..f1675d07 --- /dev/null +++ b/tests/integration/test_task_service_no_silent_fallback.py @@ -0,0 +1,123 @@ +"""TaskService.create_subtask must raise on empty acceptance_criteria. + +The pre-migration silent fallback at services/task.py:5061-5062 +substituted ['completed and reviewed by assignee']. After this task, +that fallback is gone — empty input raises TaskCompletenessError, and +the legacy placeholder phrase itself is denylisted by +foundation.policy.task_completeness so callers cannot smuggle it in +either. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +import pytest_asyncio +from roboco.db.tables import AgentTable, ProjectTable +from roboco.foundation.policy.task_completeness import TaskCompletenessError +from roboco.models import AgentRole, AgentStatus, Team +from roboco.models.base import Complexity, TaskNature, TaskType +from roboco.models.task import TaskCreateRequest +from roboco.services.task import TaskService + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest_asyncio.fixture +async def task_setup( + db_session: AsyncSession, +) -> AsyncIterator[dict]: + """Minimal seed: one developer + one project, returning a TaskService.""" + agent = AgentTable( + id=uuid4(), + name="Dev", + slug=f"be-dev-{uuid4().hex[:8]}", + role=AgentRole.DEVELOPER, + team=Team.BACKEND, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="dev", + capabilities=[], + permissions={}, + metrics={}, + ) + db_session.add(agent) + await db_session.flush() + project = ProjectTable( + id=uuid4(), + name="No-Fallback-Proj", + slug=f"no-fb-{uuid4().hex[:8]}", + git_url="https://example.com/r.git", + assigned_cell=Team.BACKEND, + created_by=agent.id, + ) + db_session.add(project) + await db_session.flush() + yield { + "svc": TaskService(db_session), + "agent_id": agent.id, + "project_id": project.id, + } + + +def _request(setup: dict, **overrides: object) -> TaskCreateRequest: + """Build a TaskCreateRequest with sensible defaults; overrides win. + + `TaskCreateRequest` is a plain dataclass — no Pydantic validation — + so a caller CAN construct it with `acceptance_criteria=[]` and the + only thing standing between that input and a skeleton row in the DB + is the service-layer check we are adding in this task. + """ + parent_id = overrides.pop("parent_task_id", uuid4()) + return TaskCreateRequest( + title=overrides.pop("title", "ok"), + description=overrides.pop( + "description", + "A description that's at least twenty chars long for the constraint.", + ), + acceptance_criteria=overrides.pop("acceptance_criteria", ["ac"]), + team=overrides.pop("team", Team.BACKEND), + created_by=setup["agent_id"], + project_id=setup["project_id"], + parent_task_id=parent_id, + task_type=overrides.pop("task_type", TaskType.CODE), + nature=overrides.pop("nature", TaskNature.TECHNICAL), + estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM), + **overrides, # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_create_subtask_raises_on_empty_acceptance_criteria( + task_setup: dict, +) -> None: + """Empty list → TaskCompletenessError (no silent placeholder substitution).""" + svc: TaskService = task_setup["svc"] + request = _request(task_setup, acceptance_criteria=[]) + with pytest.raises(TaskCompletenessError) as exc_info: + await svc.create_subtask(request) + assert "acceptance_criteria" in exc_info.value.missing + + +@pytest.mark.asyncio +async def test_create_subtask_rejects_legacy_fallback_phrase( + task_setup: dict, +) -> None: + """The legacy fallback string is denylisted at the policy layer. + + Even if a caller hand-types the exact phrase the deleted fallback used to + insert, the completeness check rejects it as a known evasion. + """ + svc: TaskService = task_setup["svc"] + request = _request( + task_setup, + acceptance_criteria=["completed and reviewed by assignee"], + ) + with pytest.raises(TaskCompletenessError) as exc_info: + await svc.create_subtask(request) + assert "acceptance_criteria" in exc_info.value.missing diff --git a/tests/integration/test_task_service_route_orchestration.py b/tests/integration/test_task_service_route_orchestration.py index ffb547d0..7f6393e7 100644 --- a/tests/integration/test_task_service_route_orchestration.py +++ b/tests/integration/test_task_service_route_orchestration.py @@ -19,8 +19,11 @@ import pytest_asyncio from roboco.db.tables import AgentTable, ProjectTable from roboco.models import AgentRole, AgentStatus, Team from roboco.models.base import ( + Complexity, SubstituteReason, + TaskNature, TaskStatus, + TaskType, ) from roboco.models.permissions import AgentContext from roboco.models.task import TaskCreateRequest @@ -101,6 +104,9 @@ def _req(setup: dict, **overrides) -> TaskCreateRequest: team=overrides.pop("team", Team.BACKEND), created_by=setup["agent_id"], project_id=setup["project_id"], + task_type=overrides.pop("task_type", TaskType.CODE), + nature=overrides.pop("nature", TaskNature.TECHNICAL), + estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM), **overrides, ) diff --git a/tests/integration/test_task_service_transitions.py b/tests/integration/test_task_service_transitions.py index bb864885..a7842ec7 100644 --- a/tests/integration/test_task_service_transitions.py +++ b/tests/integration/test_task_service_transitions.py @@ -18,8 +18,10 @@ from roboco.events import EventType from roboco.models import AgentRole, AgentStatus, Team from roboco.models.base import ( BlockerResolverType, + Complexity, TaskNature, TaskStatus, + TaskType, ) from roboco.models.task import TaskCreateRequest from roboco.services.base import NotFoundError @@ -76,6 +78,9 @@ def _req(setup: dict, **overrides) -> TaskCreateRequest: team=overrides.pop("team", Team.BACKEND), created_by=setup["agent_id"], project_id=setup["project_id"], + task_type=overrides.pop("task_type", TaskType.CODE), + nature=overrides.pop("nature", TaskNature.TECHNICAL), + estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM), **overrides, ) @@ -1644,11 +1649,14 @@ async def test_create_subtask_requires_parent_task_id(task_setup: dict) -> None: async def test_create_subtask_with_assignee_uses_pending( task_setup: dict, ) -> None: + # `create_subtask` enforces TASK_AT_CREATE completeness (Task 18, 2026-05-10), + # so we pass a description that meets the 20-char minimum. svc = task_setup["svc"] parent = await svc.create(_req(task_setup)) sub = await svc.create_subtask( _req( task_setup, + description="Subtask with explicit description for completeness rule.", parent_task_id=parent.id, assigned_to=task_setup["agent_id"], ) @@ -1660,10 +1668,16 @@ async def test_create_subtask_with_assignee_uses_pending( async def test_create_subtask_without_assignee_uses_backlog( task_setup: dict, ) -> None: + # See test above re: completeness rule on description length. svc = task_setup["svc"] parent = await svc.create(_req(task_setup)) sub = await svc.create_subtask( - _req(task_setup, parent_task_id=parent.id, assigned_to=None) + _req( + task_setup, + description="Subtask with explicit description for completeness rule.", + parent_task_id=parent.id, + assigned_to=None, + ) ) assert sub.status == TaskStatus.BACKLOG diff --git a/tests/integration/test_tasks_routes.py b/tests/integration/test_tasks_routes.py index bd2cb7e6..c03a08a2 100644 --- a/tests/integration/test_tasks_routes.py +++ b/tests/integration/test_tasks_routes.py @@ -151,10 +151,13 @@ async def test_create_task(task_client: dict) -> None: "/api/tasks", json={ "title": "Test Task", - "description": "Some description", + "description": "Some description that is long enough for the schema", "acceptance_criteria": ["criteria"], "team": "backend", "project_id": str(task_client["project"].id), + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", }, headers=_HDR, ) @@ -605,11 +608,14 @@ async def test_create_task_assigned_to_uuid(task_client: dict) -> None: "/api/tasks", json={ "title": "T", - "description": "d", + "description": "Twenty character description here ok", "acceptance_criteria": ["a"], "team": "backend", "project_id": str(task_client["project"].id), "assigned_to": str(task_client["agent"].id), + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", }, headers=_HDR, ) @@ -624,11 +630,14 @@ async def test_create_task_assigned_to_slug(task_client: dict) -> None: "/api/tasks", json={ "title": "T", - "description": "d", + "description": "Twenty character description here ok", "acceptance_criteria": ["a"], "team": "backend", "project_id": str(task_client["project"].id), "assigned_to": task_client["agent"].slug, + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", }, headers=_HDR, ) @@ -1527,10 +1536,13 @@ async def test_create_task_role_not_authorized(task_client: dict) -> None: "/api/tasks", json={ "title": "T", - "description": "d", + "description": "Twenty character description here ok", "acceptance_criteria": ["a"], "team": "backend", "project_id": str(task_client["project"].id), + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", }, headers=_HDR, ) diff --git a/tests/unit/agent_sdk/test_verb_circuit_breaker.py b/tests/unit/agent_sdk/test_verb_circuit_breaker.py new file mode 100644 index 00000000..ea8bbae5 --- /dev/null +++ b/tests/unit/agent_sdk/test_verb_circuit_breaker.py @@ -0,0 +1,416 @@ +"""Per-verb circuit breaker — agent_sdk denies after N retries in 60s. + +Phase 3 Task 14. The 2026-05-10 smoke run showed `i_am_done` retried 5+ +times in 2 minutes (each rejection was a `tracing_gap`; the agent kept +calling). This module verifies the runtime tracker added to +`agent_sdk.server` enforces the per-verb cap from +`foundation.policy.agent_loop.retry_limit_for`. + +The tracker is keyed on `(verb, task_id)` and operates over a 60s sliding +window. Tests poke time.monotonic via patch to drive window decay +deterministically — wallclock sleeps would slow the suite. +""" + +from __future__ import annotations + +from collections import deque +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest +import roboco.agent_sdk.server as srv +from fastapi.testclient import TestClient +from roboco.foundation.policy.agent_loop import retry_limit_for +from roboco.services.gateway.envelope import Envelope + +if TYPE_CHECKING: + from collections.abc import Iterator + + +# Named constants — ruff PLR2004 forbids magic comparisons. Mirror the +# foundation cap for i_am_done so the tests remain readable. +_OK = 200 +_I_AM_DONE_CAP = 3 # foundation.VERB_RETRY_LIMITS["i_am_done"] + + +@pytest.fixture(autouse=True) +def _reset_state() -> Iterator[None]: + """Wipe the SDK session state before every test. + + Helpers share a module-level `_state` singleton, so leakage between + tests would silently corrupt counts. + """ + srv._state.reset() + yield + srv._state.reset() + + +# --------------------------------------------------------------------------- +# Envelope.circuit_open +# --------------------------------------------------------------------------- + + +def test_envelope_circuit_open_kind() -> None: + """Envelope.circuit_open is a distinct error kind.""" + env = Envelope.circuit_open( + verb="i_am_done", + attempts=4, + window_seconds=60, + remediate="call i_am_blocked", + ) + body = env.as_dict() + assert body["error"] == "circuit_open" + assert body["remediate"] == "call i_am_blocked" + assert body["message"] is not None + assert "i_am_done" in body["message"] + assert "60" in body["message"] + assert "4" in body["message"] + + +def test_envelope_circuit_open_carries_briefing() -> None: + """Optional context_briefing is preserved when caller supplies it.""" + env = Envelope.circuit_open( + verb="i_am_done", + attempts=4, + window_seconds=60, + remediate="x", + context_briefing={"hint": "blocked"}, + ) + body = env.as_dict() + assert body["context_briefing"] == {"hint": "blocked"} + + +def test_envelope_circuit_open_default_briefing_empty() -> None: + """Omitted context_briefing defaults to an empty dict (not None).""" + env = Envelope.circuit_open( + verb="i_am_done", attempts=3, window_seconds=60, remediate="x" + ) + body = env.as_dict() + assert body["context_briefing"] == {} + + +# --------------------------------------------------------------------------- +# Tracker — record + count +# --------------------------------------------------------------------------- + + +def test_record_attempt_increments_count_for_same_key() -> None: + """Three records for the same (verb, task_id) yield count == 3.""" + expected = 3 + for _ in range(expected): + srv._record_verb_attempt("i_am_done", "task-A") + assert srv._verb_attempt_count("i_am_done", "task-A") == expected + + +def test_tracker_keys_per_verb_task_pair() -> None: + """The tracker keys on (verb, task_id), not just verb. + + Records for task-A are independent of records for task-B even when the + verb is identical. Without this guarantee, an agent juggling two tasks + would trip the breaker on the wrong one. + """ + a_count = 3 + b_count = 1 + for _ in range(a_count): + srv._record_verb_attempt("i_am_done", "task-A") + for _ in range(b_count): + srv._record_verb_attempt("i_am_done", "task-B") + + assert srv._verb_attempt_count("i_am_done", "task-A") == a_count + assert srv._verb_attempt_count("i_am_done", "task-B") == b_count + + +def test_tracker_keys_per_verb_independent_of_task() -> None: + """Different verbs on the same task are tracked independently.""" + done_count = 2 + submit_count = 1 + for _ in range(done_count): + srv._record_verb_attempt("i_am_done", "task-A") + for _ in range(submit_count): + srv._record_verb_attempt("submit_up", "task-A") + + assert srv._verb_attempt_count("i_am_done", "task-A") == done_count + assert srv._verb_attempt_count("submit_up", "task-A") == submit_count + + +def test_tracker_handles_none_task_id() -> None: + """Verbs without a task_id collapse to (verb, None) — still tracked.""" + expected = 2 + for _ in range(expected): + srv._record_verb_attempt("triage", None) + assert srv._verb_attempt_count("triage", None) == expected + + +def test_count_is_zero_for_unknown_key() -> None: + """Querying a never-recorded key returns 0 (no KeyError).""" + assert srv._verb_attempt_count("never_called", "task-X") == 0 + + +# --------------------------------------------------------------------------- +# Tracker — window decay +# --------------------------------------------------------------------------- + + +def test_window_drops_entries_older_than_60s() -> None: + """Attempts older than 60s are pruned from the window on next access. + + Drives time.monotonic via patch so the test runs in <1ms instead of + waiting on wall-clock. + """ + base = 1000.0 + initial = 3 + after_jump = 1 + with patch("roboco.agent_sdk.server.time.monotonic") as mock_time: + mock_time.return_value = base + for _ in range(initial): + srv._record_verb_attempt("i_am_done", "task-A") + assert srv._verb_attempt_count("i_am_done", "task-A") == initial + + # Jump past the 60s window — old attempts must be dropped. + mock_time.return_value = base + 61.0 + srv._record_verb_attempt("i_am_done", "task-A") + # The 3 old entries are pruned; only the new one remains. + assert srv._verb_attempt_count("i_am_done", "task-A") == after_jump + + +def test_window_keeps_entries_within_60s() -> None: + """Attempts within the window survive — only ones past 60s drop.""" + base = 1000.0 + expected = 2 + with patch("roboco.agent_sdk.server.time.monotonic") as mock_time: + mock_time.return_value = base + srv._record_verb_attempt("i_am_done", "task-A") + + mock_time.return_value = base + 30.0 + srv._record_verb_attempt("i_am_done", "task-A") + + mock_time.return_value = base + 59.0 + # Both entries within the 60s window starting at base. + assert srv._verb_attempt_count("i_am_done", "task-A") == expected + + +def test_count_prunes_on_read_without_recording() -> None: + """_verb_attempt_count prunes the window even when not recording. + + A read-only check should still see a fresh count after time advances — + callers (e.g. _check_verb_circuit) rely on this. + """ + base = 1000.0 + with patch("roboco.agent_sdk.server.time.monotonic") as mock_time: + mock_time.return_value = base + srv._record_verb_attempt("i_am_done", "task-A") + srv._record_verb_attempt("i_am_done", "task-A") + + mock_time.return_value = base + 61.0 + # Count must drop without us recording anything new. + assert srv._verb_attempt_count("i_am_done", "task-A") == 0 + + +# --------------------------------------------------------------------------- +# _check_verb_circuit — gating logic +# --------------------------------------------------------------------------- + + +def test_check_returns_none_when_under_limit() -> None: + """Below the cap, _check_verb_circuit returns None — call may proceed.""" + srv._record_verb_attempt("i_am_done", "task-A") + srv._record_verb_attempt("i_am_done", "task-A") + # i_am_done cap is 3; we recorded 2. + assert srv._check_verb_circuit("i_am_done", "task-A") is None + + +def test_check_returns_envelope_at_or_above_limit() -> None: + """At the cap, _check_verb_circuit returns a circuit_open envelope dict.""" + limit = retry_limit_for("i_am_done") + assert limit is not None + for _ in range(limit): + srv._record_verb_attempt("i_am_done", "task-A") + + result = srv._check_verb_circuit("i_am_done", "task-A") + assert result is not None + assert result["error"] == "circuit_open" + assert "i_am_done" in result["message"] + assert result["remediate"] is not None + assert "i_am_blocked" in result["remediate"] + + +def test_check_returns_none_for_unlimited_retry_verbs() -> None: + """give_me_work / triage / evidence never trip the breaker.""" + assert retry_limit_for("give_me_work") is None + # Even after many recorded attempts, no envelope is returned. + for _ in range(20): + srv._record_verb_attempt("give_me_work", None) + assert srv._check_verb_circuit("give_me_work", None) is None + + +def test_check_uses_default_cap_for_unknown_verb() -> None: + """Unknown verbs fall back to BudgetPolicy.verb_retry_max_per_minute.""" + limit = retry_limit_for("not_a_real_verb") + assert limit is not None # default cap applies + for _ in range(limit): + srv._record_verb_attempt("not_a_real_verb", "task-A") + assert srv._check_verb_circuit("not_a_real_verb", "task-A") is not None + + +# --------------------------------------------------------------------------- +# /verb/attempted endpoint +# --------------------------------------------------------------------------- + + +def test_verb_attempted_endpoint_records_rejection() -> None: + """POST /verb/attempted with a counted rejection_kind increments the window.""" + client = TestClient(srv.app) + resp = client.post( + "/verb/attempted", + json={ + "verb": "i_am_done", + "task_id": "task-A", + "rejection_kind": "tracing_gap", + }, + ) + assert resp.status_code == _OK + body = resp.json() + assert body["verb"] == "i_am_done" + assert body["task_id"] == "task-A" + assert body["attempts"] == 1 + assert body["limit"] == retry_limit_for("i_am_done") + assert body["open"] is False + assert body["circuit_envelope"] is None + + +def test_verb_attempted_endpoint_ignores_uncounted_kinds() -> None: + """A non-rejection (e.g. ok-related) kind doesn't move the counter.""" + client = TestClient(srv.app) + resp = client.post( + "/verb/attempted", + json={ + "verb": "i_am_done", + "task_id": "task-A", + "rejection_kind": "ok", # not in the counted set + }, + ) + assert resp.status_code == _OK + body = resp.json() + assert body["attempts"] == 0 + assert body["open"] is False + + +def test_verb_attempted_endpoint_opens_circuit_at_threshold() -> None: + """After `limit` rejections in 60s the endpoint reports open=True with envelope.""" + client = TestClient(srv.app) + limit = retry_limit_for("i_am_done") + assert limit is not None + + # Hit the cap. + last_body: dict[str, object] | None = None + for _ in range(limit): + resp = client.post( + "/verb/attempted", + json={ + "verb": "i_am_done", + "task_id": "task-A", + "rejection_kind": "tracing_gap", + }, + ) + assert resp.status_code == _OK + last_body = resp.json() + + assert last_body is not None + assert last_body["attempts"] == limit + assert last_body["open"] is True + env = last_body["circuit_envelope"] + assert isinstance(env, dict) + assert env["error"] == "circuit_open" + assert "i_am_done" in env["message"] + + +def test_verb_attempted_endpoint_unlimited_verb_never_opens() -> None: + """give_me_work bypasses the breaker even after many rejections.""" + client = TestClient(srv.app) + for _ in range(10): + resp = client.post( + "/verb/attempted", + json={ + "verb": "give_me_work", + "task_id": None, + "rejection_kind": "tracing_gap", + }, + ) + assert resp.status_code == _OK + + body = resp.json() + assert body["limit"] is None + assert body["open"] is False + assert body["circuit_envelope"] is None + + +def test_verb_circuit_status_endpoint_does_not_record() -> None: + """GET /verb/circuit_status reads state without incrementing.""" + client = TestClient(srv.app) + # Record one rejection via the POST endpoint. + client.post( + "/verb/attempted", + json={ + "verb": "i_am_done", + "task_id": "task-A", + "rejection_kind": "tracing_gap", + }, + ) + # Now poll status repeatedly — count must stay at 1. + for _ in range(5): + resp = client.get( + "/verb/circuit_status", + params={"verb": "i_am_done", "task_id": "task-A"}, + ) + assert resp.status_code == _OK + assert resp.json()["attempts"] == 1 + + +# --------------------------------------------------------------------------- +# Reset semantics +# --------------------------------------------------------------------------- + + +def test_state_reset_clears_verb_attempts() -> None: + """_state.reset() wipes the verb tracker (orchestrator calls this on spawn).""" + expected = 2 + for _ in range(expected): + srv._record_verb_attempt("i_am_done", "task-A") + assert srv._verb_attempt_count("i_am_done", "task-A") == expected + + srv._state.reset() + assert srv._verb_attempt_count("i_am_done", "task-A") == 0 + + +def test_state_reset_via_endpoint_clears_verb_attempts() -> None: + """POST /budget/reset (called by orchestrator on spawn) clears the tracker too.""" + client = TestClient(srv.app) + client.post( + "/verb/attempted", + json={ + "verb": "i_am_done", + "task_id": "task-A", + "rejection_kind": "tracing_gap", + }, + ) + assert srv._verb_attempt_count("i_am_done", "task-A") == 1 + + resp = client.post("/budget/reset") + assert resp.status_code == _OK + assert srv._verb_attempt_count("i_am_done", "task-A") == 0 + + +def test_verb_attempts_default_is_empty_deque() -> None: + """defaultdict yields an empty deque for unseen keys — sanity check.""" + fresh = srv._SessionState() + assert isinstance(fresh.verb_attempts[("never_seen", None)], deque) + assert len(fresh.verb_attempts[("never_seen", None)]) == 0 + + +def test_i_am_done_cap_matches_foundation() -> None: + """The local _I_AM_DONE_CAP constant tracks foundation. + + If foundation changes the cap, this test fails so the test constants + are updated alongside. + """ + assert retry_limit_for("i_am_done") == _I_AM_DONE_CAP diff --git a/tests/unit/api/routes/v2/test_flow_cell_pm.py b/tests/unit/api/routes/v2/test_flow_cell_pm.py index 3327ee44..545770a7 100644 --- a/tests/unit/api/routes/v2/test_flow_cell_pm.py +++ b/tests/unit/api/routes/v2/test_flow_cell_pm.py @@ -255,10 +255,13 @@ async def test_delegate_dispatches_inputs_bundle() -> None: json={ "parent_task_id": _TASK_ID, "title": "Implement /v1/foo", - "description": "Add the foo endpoint with tests.", + "description": "Add the foo endpoint with passing tests.", "assigned_to": "be-dev-1", "team": "backend", "task_type": "code", + "nature": "feature", + "estimated_complexity": "medium", + "acceptance_criteria": ["GET /v1/foo returns 200 with body"], }, headers=_HEADERS, ) diff --git a/tests/unit/api/routes/v2/test_flow_main_pm.py b/tests/unit/api/routes/v2/test_flow_main_pm.py index 1351ba5e..67e22d62 100644 --- a/tests/unit/api/routes/v2/test_flow_main_pm.py +++ b/tests/unit/api/routes/v2/test_flow_main_pm.py @@ -226,10 +226,16 @@ async def test_delegate_to_cell_pm_dispatches_inputs_bundle() -> None: json={ "parent_task_id": _TASK_ID, "title": "Backend slice", - "description": "Plan + drive backend work for feature X.", + "description": "Plan + drive backend work for feature X end to end.", "assigned_to": "be-pm", "team": "backend", "task_type": "planning", + "nature": "feature", + "estimated_complexity": "high", + "acceptance_criteria": [ + "all subtasks created with acceptance criteria", + "branch + PR opened against the slice", + ], }, headers=_HEADERS, ) diff --git a/tests/unit/api/test_delegate_request_completeness.py b/tests/unit/api/test_delegate_request_completeness.py new file mode 100644 index 00000000..ca9ce6bc --- /dev/null +++ b/tests/unit/api/test_delegate_request_completeness.py @@ -0,0 +1,68 @@ +"""DelegateRequest schema must enforce TASK_AT_CREATE field constraints.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from roboco.api.schemas.v2.flow import DelegateRequest + + +def _ok_payload() -> dict: + return { + "parent_task_id": uuid4(), + "title": "Add user lookup endpoint", + "description": ( + "Add GET /v1/users/{id} returning the user JSON for the dashboard." + ), + "assigned_to": "be-dev-1", + "team": "backend", + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "acceptance_criteria": [ + "returns 404 for unknown user", + "returns 200 + user JSON for known user", + ], + } + + +def test_delegate_request_accepts_complete_payload() -> None: + DelegateRequest(**_ok_payload()) + + +def test_delegate_request_rejects_empty_acceptance_criteria() -> None: + payload = _ok_payload() + payload["acceptance_criteria"] = [] + with pytest.raises(ValidationError) as exc_info: + DelegateRequest(**payload) + assert "acceptance_criteria" in str(exc_info.value) + + +def test_delegate_request_rejects_missing_acceptance_criteria() -> None: + payload = _ok_payload() + del payload["acceptance_criteria"] + with pytest.raises(ValidationError): + DelegateRequest(**payload) + + +def test_delegate_request_rejects_missing_nature() -> None: + payload = _ok_payload() + del payload["nature"] + with pytest.raises(ValidationError): + DelegateRequest(**payload) + + +def test_delegate_request_rejects_missing_estimated_complexity() -> None: + payload = _ok_payload() + del payload["estimated_complexity"] + with pytest.raises(ValidationError): + DelegateRequest(**payload) + + +def test_delegate_request_rejects_short_description() -> None: + payload = _ok_payload() + payload["description"] = "x" + with pytest.raises(ValidationError): + DelegateRequest(**payload) diff --git a/tests/unit/api/test_schemas_v2_flow.py b/tests/unit/api/test_schemas_v2_flow.py index 3edb655e..89ed974a 100644 --- a/tests/unit/api/test_schemas_v2_flow.py +++ b/tests/unit/api/test_schemas_v2_flow.py @@ -22,9 +22,12 @@ def test_delegate_request_requires_task_type() -> None: DelegateRequest( parent_task_id=uuid4(), title="t", - description="d", + description="add the new endpoint plus tests", assigned_to="be-dev-1", team="backend", + nature="technical", + estimated_complexity="medium", + acceptance_criteria=["returns 200"], # task_type intentionally omitted ) assert "task_type" in str(exc.value) @@ -34,9 +37,12 @@ def test_delegate_request_accepts_explicit_task_type() -> None: req = DelegateRequest( parent_task_id=uuid4(), title="t", - description="d", + description="add the new endpoint plus tests", assigned_to="be-dev-1", team="backend", task_type="code", + nature="technical", + estimated_complexity="medium", + acceptance_criteria=["returns 200"], ) assert req.task_type == "code" diff --git a/tests/unit/api/test_task_update_completeness.py b/tests/unit/api/test_task_update_completeness.py new file mode 100644 index 00000000..7b30e683 --- /dev/null +++ b/tests/unit/api/test_task_update_completeness.py @@ -0,0 +1,43 @@ +"""TaskUpdate (PATCH /tasks/{id}) must reject blanking acceptance_criteria. + +The Golden Rule "no task without acceptance criteria" applies to every +mutation, not just creation. Setting acceptance_criteria to [] or None +in a PATCH is a Golden Rule violation. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError +from roboco.api.schemas.tasks import TaskUpdate + + +def test_task_update_accepts_omitting_acceptance_criteria() -> None: + """Omitting the field is fine — caller isn't touching it.""" + upd = TaskUpdate(title="new title") + assert upd.acceptance_criteria is None + + +def test_task_update_accepts_replacing_acceptance_criteria_with_non_empty_list() -> ( + None +): + upd = TaskUpdate(acceptance_criteria=["new criterion"]) + assert upd.acceptance_criteria == ["new criterion"] + + +def test_task_update_rejects_empty_acceptance_criteria() -> None: + """Empty list = "blank the criteria" = Golden Rule violation.""" + with pytest.raises(ValidationError): + TaskUpdate(acceptance_criteria=[]) + + +def test_task_update_rejects_explicit_none_when_present() -> None: + """Pydantic differentiates omitted (default) from explicitly None.""" + with pytest.raises(ValidationError): + TaskUpdate(**{"acceptance_criteria": None}) + + +def test_task_update_rejects_short_description() -> None: + """min_length applies on PATCH too.""" + with pytest.raises(ValidationError): + TaskUpdate(description="x") diff --git a/tests/unit/enforcement/test_notification_perms.py b/tests/unit/enforcement/test_notification_perms.py deleted file mode 100644 index 8f3761a6..00000000 --- a/tests/unit/enforcement/test_notification_perms.py +++ /dev/null @@ -1,88 +0,0 @@ -"""enforcement.notification_perms coverage.""" - -from __future__ import annotations - -import pytest -from roboco.enforcement.notification_perms import ( - NotificationPermissionError, - _can_send_to_recipient, - get_notification_scope, - validate_notification_permission, -) - - -def test_developer_cannot_send_notifications() -> None: - with pytest.raises(NotificationPermissionError, match="cannot send"): - validate_notification_permission("be-dev-1", ["be-pm"]) - - -def test_main_pm_can_send_to_anyone() -> None: - assert validate_notification_permission("main-pm", ["be-dev-1"]) is True - - -def test_cell_pm_can_notify_cell_member() -> None: - assert validate_notification_permission("be-pm", ["be-dev-1"]) is True - - -def test_cell_pm_can_notify_other_cell_pm() -> None: - assert validate_notification_permission("be-pm", ["fe-pm"]) is True - - -def test_cell_pm_can_notify_main_pm() -> None: - assert validate_notification_permission("be-pm", ["main-pm"]) is True - - -def test_cell_pm_cannot_notify_other_cell_dev() -> None: - with pytest.raises(NotificationPermissionError): - validate_notification_permission("be-pm", ["fe-dev-1"]) - - -def test_get_notification_scope_for_main_pm() -> None: - scope = get_notification_scope("main-pm") - assert scope.get("can_send") is True - - -def test_get_notification_scope_for_developer() -> None: - scope = get_notification_scope("be-dev-1") - assert scope.get("can_send") is False - - -def test_get_notification_scope_for_unknown() -> None: - scope = get_notification_scope("ghost-agent") - assert scope.get("can_send") is False - - -def test_validate_with_multiple_recipients() -> None: - """Validate succeeds when all recipients are reachable.""" - assert validate_notification_permission("main-pm", ["be-dev-1", "fe-dev-1"]) is True - - -def test_validate_fails_on_first_unreachable() -> None: - """Validation halts at the first unreachable recipient.""" - with pytest.raises(NotificationPermissionError): - validate_notification_permission("be-pm", ["be-dev-1", "fe-dev-1"]) - - -def test_unknown_agent_cannot_send() -> None: - with pytest.raises(NotificationPermissionError): - validate_notification_permission("ghost-agent", ["be-pm"]) - - -def test_can_send_to_recipient_developer_role_blocked() -> None: - """_can_send_to_recipient with no can_send → role-blocked reason (line 51).""" - - can_send, reason = _can_send_to_recipient("be-dev-1", "be-pm") - assert can_send is False - assert "developer" in reason - - -def test_board_member_list_scope_can_notify_listed_target() -> None: - """Lines 73-75: list-scope sender notifies recipient in list.""" - # product_owner has list scope including 'main-pm'. - assert validate_notification_permission("product-owner", ["main-pm"]) is True - - -def test_board_member_list_scope_cannot_notify_unlisted_target() -> None: - """Lines 76-77: list-scope sender to unlisted target → False reason.""" - with pytest.raises(NotificationPermissionError): - validate_notification_permission("product-owner", ["be-dev-1"]) diff --git a/tests/unit/gateway/test_auditor_silent_guard.py b/tests/unit/gateway/test_auditor_silent_guard.py new file mode 100644 index 00000000..89f0d26b --- /dev/null +++ b/tests/unit/gateway/test_auditor_silent_guard.py @@ -0,0 +1,126 @@ +"""Auditor is silent — runtime guard refuses say()/dm(). + +Spec §5.5: the auditor is a silent observer. The spawn manifest already +omits `say` and `dm` from the auditor's tool surface, but that is a +convention-only defense. These tests pin a defense-in-depth runtime guard +inside ContentActions.say/dm: if the caller's role is "auditor", the +verb refuses with Envelope.not_authorized regardless of how the call +arrived. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from roboco.services.gateway.content_actions import ( + ContentActions, + ContentActionsDeps, +) + + +def _make_deps(agent_role: str, **overrides: AsyncMock) -> ContentActionsDeps: + """Build a ContentActionsDeps whose task.agent_for returns the given role.""" + if "task" in overrides: + task = overrides["task"] + else: + task = AsyncMock() + task.get_active_task_for_agent.return_value = None + task.agent_for.return_value = MagicMock(role=agent_role) + + git = overrides.get("git", AsyncMock()) + messaging = overrides.get("messaging", AsyncMock()) + a2a = overrides.get("a2a", AsyncMock()) + journal = overrides.get("journal", AsyncMock()) + workspace = overrides.get("workspace", AsyncMock()) + notifications = overrides.get("notifications", AsyncMock()) + return ContentActionsDeps( + task=task, + git=git, + messaging=messaging, + a2a=a2a, + journal=journal, + workspace=workspace, + notifications=notifications, + ) + + +@pytest.mark.asyncio +async def test_auditor_say_returns_not_authorized() -> None: + """Auditor role calling say() is refused regardless of manifest.""" + auditor_id = uuid4() + deps = _make_deps("auditor") + actions = ContentActions(deps) + + env = await actions.say(agent_id=auditor_id, channel="backend-cell", text="hi") + body = env.as_dict() + + assert body["error"] == "not_authorized" + haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") + assert "silent" in haystack.lower() or "auditor" in haystack.lower() + # The messaging service must not have been touched — the guard fires + # before any downstream call. + deps.messaging.post_to_channel.assert_not_called() + + +@pytest.mark.asyncio +async def test_auditor_dm_returns_not_authorized() -> None: + """Auditor role calling dm() is refused regardless of manifest.""" + auditor_id = uuid4() + deps = _make_deps("auditor") + actions = ContentActions(deps) + + env = await actions.dm( + agent_id=auditor_id, + recipient=str(uuid4()), + text="hi", + task_id=uuid4(), + ) + body = env.as_dict() + + assert body["error"] == "not_authorized" + haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") + assert "silent" in haystack.lower() or "auditor" in haystack.lower() + deps.a2a.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_developer_say_passes_auditor_guard() -> None: + """Non-auditor roles are not blocked by the new guard. + + The messaging mock returns None so downstream flow is whatever the + happy path is — we only assert that the auditor guard didn't fire + (i.e. the response is not the auditor-silent not_authorized envelope). + """ + dev_id = uuid4() + deps = _make_deps("developer") + actions = ContentActions(deps) + + env = await actions.say(agent_id=dev_id, channel="backend-cell", text="hi") + body = env.as_dict() + + # The auditor-silent message is what we explicitly want to NOT see. + if body.get("error") == "not_authorized": + haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") + assert "silent" not in haystack.lower() + + +@pytest.mark.asyncio +async def test_developer_dm_passes_auditor_guard() -> None: + """dm() for a non-auditor role is not blocked by the new guard.""" + dev_id = uuid4() + deps = _make_deps("developer") + actions = ContentActions(deps) + + env = await actions.dm( + agent_id=dev_id, + recipient=str(uuid4()), + text="hi", + task_id=uuid4(), + ) + body = env.as_dict() + + if body.get("error") == "not_authorized": + haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") + assert "silent" not in haystack.lower() diff --git a/tests/unit/gateway/test_choreographer_board.py b/tests/unit/gateway/test_choreographer_board.py index 833eacba..deed5ce7 100644 --- a/tests/unit/gateway/test_choreographer_board.py +++ b/tests/unit/gateway/test_choreographer_board.py @@ -25,6 +25,19 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "evidence_repo": AsyncMock(), } base.update(overrides) + # VerbRunner wraps composed atomic actions in + # ``task.session.begin_nested()``. AsyncMock auto-attribute access + # would return an unawaitable coroutine, breaking the + # ``async with`` protocol. Overwrite session with a MagicMock that + # implements the async-context-manager protocol explicitly. + task_dep = base["task"] + task_dep.session = MagicMock() + task_dep.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) repo = base["evidence_repo"] for method in ( "list_unread_a2a", @@ -62,7 +75,7 @@ async def test_board_escalate_to_ceo_succeeds_for_product_owner() -> None: assert env.error is None assert env.status == "awaiting_ceo_approval" task_svc.escalate_to_ceo.assert_awaited_once_with( - task_id, + task_id=task_id, agent_role="product_owner", notes="ready for CEO sign-off", ) @@ -91,7 +104,7 @@ async def test_board_escalate_to_ceo_succeeds_for_head_marketing() -> None: assert env.error is None assert env.status == "awaiting_ceo_approval" task_svc.escalate_to_ceo.assert_awaited_once_with( - task_id, + task_id=task_id, agent_role="head_marketing", notes="brand-affecting change", ) @@ -191,7 +204,7 @@ async def test_board_escalate_to_ceo_succeeds_for_main_pm() -> None: assert env.error is None assert env.status == "awaiting_ceo_approval" task_svc.escalate_to_ceo.assert_awaited_once_with( - task_id, + task_id=task_id, agent_role="main_pm", notes="root task done", ) diff --git a/tests/unit/gateway/test_choreographer_claim_guards.py b/tests/unit/gateway/test_choreographer_claim_guards.py index 4fa82e87..ae0feab7 100644 --- a/tests/unit/gateway/test_choreographer_claim_guards.py +++ b/tests/unit/gateway/test_choreographer_claim_guards.py @@ -33,6 +33,18 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "evidence_repo": AsyncMock(), } base.update(overrides) + # VerbRunner uses task.session.begin_nested() as a savepoint context + # manager. AsyncMock auto-attributes any access (so hasattr always + # returns True); we always overwrite session to a MagicMock with the + # correct async-context-manager protocol. + task = base["task"] + task.session = MagicMock() + task.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) repo = base["evidence_repo"] for method in ( "list_unread_a2a", @@ -51,17 +63,27 @@ def _task_svc_with( target: MagicMock, *, role: str = "developer", - in_progress: list[MagicMock] | None = None, - paused: list[MagicMock] | None = None, - siblings: list[MagicMock] | None = None, + agent_id: object | None = None, + lookups: dict[str, list[MagicMock]] | None = None, ) -> AsyncMock: - """Build a task service mock primed with the active-task and sibling lookups.""" + """Build a task service mock primed with the active-task and sibling lookups. + + `agent_id` (when supplied) is used as the GatewayAgentView's id so that + runner-driven calls like ``task.claim(task.id, agent.id)`` line up + with the test's assert_awaited_with(target_id, agent_id). + + `lookups` carries the optional in_progress / paused / siblings lists + (defaulting empty). One bag avoids ruff PLR0913 on the helper sig. + """ + lookups = lookups or {} task_svc = AsyncMock() task_svc.get.return_value = target - task_svc.agent_for.return_value = MagicMock(role=role, team="backend") - task_svc.list_in_progress_for_agent.return_value = in_progress or [] - task_svc.list_paused_for_agent.return_value = paused or [] - task_svc.get_subtasks.return_value = siblings or [] + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role=role, team="backend", slug=None + ) + task_svc.list_in_progress_for_agent.return_value = lookups.get("in_progress", []) + task_svc.list_paused_for_agent.return_value = lookups.get("paused", []) + task_svc.get_subtasks.return_value = lookups.get("siblings", []) return task_svc @@ -98,7 +120,7 @@ async def test_i_will_work_on_blocks_when_earlier_sibling_open() -> None: status="pending", sequence=2, ) - task_svc = _task_svc_with(target, siblings=[earlier, later]) + task_svc = _task_svc_with(target, lookups={"siblings": [earlier, later]}) deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -130,7 +152,9 @@ async def test_i_will_work_on_allows_when_earlier_sibling_terminal() -> None: earlier_cancelled = MagicMock(id=uuid4(), status="cancelled", sequence=0) self_row = MagicMock(id=target_id, status="pending", sequence=2) task_svc = _task_svc_with( - target, siblings=[earlier_done, earlier_cancelled, self_row] + target, + agent_id=agent_id, + lookups={"siblings": [earlier_done, earlier_cancelled, self_row]}, ) task_svc.claim.return_value = MagicMock( id=target_id, @@ -165,7 +189,7 @@ async def test_root_task_no_sequence_check() -> None: task_type="code", team="backend", ) - task_svc = _task_svc_with(target) + task_svc = _task_svc_with(target, agent_id=agent_id) task_svc.claim.return_value = MagicMock( id=target_id, status="claimed", plan={"x": 1}, assigned_to=agent_id ) @@ -202,7 +226,7 @@ async def test_i_will_work_on_blocks_when_agent_has_in_progress_task() -> None: team="backend", ) in_progress = MagicMock(id=other_id, status="in_progress") - task_svc = _task_svc_with(target, in_progress=[in_progress]) + task_svc = _task_svc_with(target, lookups={"in_progress": [in_progress]}) deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -233,7 +257,7 @@ async def test_i_will_work_on_resumption_does_not_self_block() -> None: started = MagicMock( id=task_id, status="in_progress", plan={"x": 1}, assigned_to=agent_id ) - task_svc = _task_svc_with(target=claimed) + task_svc = _task_svc_with(target=claimed, agent_id=agent_id) # Even if there's an in_progress task with the SAME id, that's the resumption itself task_svc.list_in_progress_for_agent.return_value = [] task_svc.start.return_value = started @@ -266,7 +290,7 @@ async def test_i_will_work_on_blocks_when_agent_has_paused_task() -> None: team="backend", ) paused = MagicMock(id=paused_id, status="paused") - task_svc = _task_svc_with(target, paused=[paused]) + task_svc = _task_svc_with(target, lookups={"paused": [paused]}) deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -304,11 +328,9 @@ async def test_cell_pm_cannot_claim_code_task_via_i_will_work_on() -> None: env = await c.i_will_work_on(pm_id, task_id, plan="x") body = env.as_dict() assert body["error"] == "not_authorized" - assert "PM" in body["message"] or "code" in body["message"].lower() - assert ( - "delegate" in body["remediate"].lower() - or "developer" in body["remediate"].lower() - ) + # Spec produces "role 'cell_pm' may not call 'i_will_work_on'". + assert "cell_pm" in body["message"] + assert "i_will_work_on" in body["message"] task_svc.claim.assert_not_awaited() @@ -411,7 +433,15 @@ async def test_pm_can_plan_non_code_parent() -> None: @pytest.mark.asyncio async def test_developer_cannot_claim_qa_status_task() -> None: - """Dev calling i_will_work_on on awaiting_qa task gets explicit rejection.""" + """Dev calling i_will_work_on on awaiting_qa task gets explicit rejection. + + spec.CLAIM_RULES restricts DEVELOPER to PENDING/NEEDS_REVISION; an + awaiting_qa task is reserved for QA. spec.can_claim surfaces this as + not_authorized (status reserved for another role) which the verb + relays via Envelope.from_decision. Pre-spec the verb body produced + invalid_state from a custom else-branch; the spec-driven body now + returns the more accurate not_authorized. + """ dev_id = uuid4() task_id = uuid4() target = MagicMock( @@ -430,13 +460,21 @@ async def test_developer_cannot_claim_qa_status_task() -> None: env = await c.i_will_work_on(dev_id, task_id) body = env.as_dict() - # Pre-existing path returns invalid_state with status complaint - assert body["error"] == "invalid_state" + assert body["error"] == "not_authorized" + assert "developer" in body["message"] + assert "awaiting_qa" in body["message"] @pytest.mark.asyncio async def test_qa_cannot_claim_code_task_via_claim_review() -> None: - """QA calling claim_review on non-awaiting_qa task is rejected by status check.""" + """QA calling claim_review on PENDING task is rejected by claim-rules. + + QA's CLAIM_RULES is {AWAITING_QA}. PENDING is owned by dev/pm — so + ``_check_claim_rules_narrow`` returns ``not_authorized`` (the + "other_role_owns_status" branch). Pre-spec the verb body's status + pre-check returned invalid_state; post-migration the spec gate + drives the rejection kind. + """ qa_id = uuid4() task_id = uuid4() target = MagicMock( @@ -448,6 +486,7 @@ async def test_qa_cannot_claim_code_task_via_claim_review() -> None: sequence=0, task_type="code", team="backend", + quick_context=None, ) task_svc = _task_svc_with(target, role="qa") deps = _make_deps(task=task_svc) @@ -455,23 +494,31 @@ async def test_qa_cannot_claim_code_task_via_claim_review() -> None: env = await c.claim_review(qa_id, task_id) body = env.as_dict() - assert body["error"] == "invalid_state" + assert body["error"] == "not_authorized" @pytest.mark.asyncio async def test_documenter_cannot_claim_code_task_via_claim_doc_task() -> None: - """Documenter calling claim_doc_task on non-awaiting-doc task is rejected.""" + """Documenter calling claim_doc_task on AWAITING_QA task is rejected by claim-rules. + + Documenter's CLAIM_RULES is {PENDING, AWAITING_DOCUMENTATION}. A + documenter calling claim_doc_task on AWAITING_QA hits the + "other_role_owns_status" branch and returns ``not_authorized``. + Pre-spec the verb body returned invalid_state on the status check; + post-migration the spec gate drives the rejection kind. + """ doc_id = uuid4() task_id = uuid4() target = MagicMock( id=task_id, - status="pending", + status="awaiting_qa", plan=None, assigned_to=None, parent_task_id=None, sequence=0, task_type="code", team="backend", + quick_context=None, ) task_svc = _task_svc_with(target, role="documenter") deps = _make_deps(task=task_svc) @@ -479,7 +526,7 @@ async def test_documenter_cannot_claim_code_task_via_claim_doc_task() -> None: env = await c.claim_doc_task(doc_id, task_id) body = env.as_dict() - assert body["error"] == "invalid_state" + assert body["error"] == "not_authorized" @pytest.mark.asyncio @@ -531,7 +578,7 @@ async def test_claim_review_blocks_when_qa_has_in_progress_task() -> None: branch_name="feature/backend/abc", ) in_progress = MagicMock(id=other_id, status="in_progress") - task_svc = _task_svc_with(target, role="qa", in_progress=[in_progress]) + task_svc = _task_svc_with(target, role="qa", lookups={"in_progress": [in_progress]}) deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -559,7 +606,7 @@ async def test_claim_doc_task_blocks_when_documenter_has_paused_task() -> None: branch_name="feature/backend/abc", ) paused = MagicMock(id=paused_id, status="paused") - task_svc = _task_svc_with(target, role="documenter", paused=[paused]) + task_svc = _task_svc_with(target, role="documenter", lookups={"paused": [paused]}) deps = _make_deps(task=task_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_choreographer_completion_guards.py b/tests/unit/gateway/test_choreographer_completion_guards.py index cc2796c5..32f37021 100644 --- a/tests/unit/gateway/test_choreographer_completion_guards.py +++ b/tests/unit/gateway/test_choreographer_completion_guards.py @@ -31,6 +31,19 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "evidence_repo": AsyncMock(), } base.update(overrides) + # VerbRunner wraps composed atomic actions in + # ``task.session.begin_nested()``. AsyncMock auto-attribute access + # would return an unawaitable coroutine, breaking the + # ``async with`` protocol. Overwrite session with a MagicMock that + # implements the async-context-manager protocol explicitly. + task_dep = base["task"] + task_dep.session = MagicMock() + task_dep.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) repo = base["evidence_repo"] for method in ( "list_unread_a2a", @@ -70,10 +83,13 @@ async def test_cell_pm_complete_blocks_when_subtask_pending() -> None: task_svc.get_subtasks.return_value = [sub] journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.cell_pm_complete(pm_id, parent_id, "done") + env = await c.cell_pm_complete( + pm_id, parent_id, "reviewed cell scope and merge ready" + ) body = env.as_dict() assert body["error"] == "tracing_gap" # Improvement: non-terminal subtask must be named. @@ -102,12 +118,13 @@ async def test_cell_pm_complete_allows_when_all_terminal() -> None: task_svc.cell_pm_complete.return_value = after journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True git_svc = AsyncMock() git_svc.pr_merge.return_value = {"merge_commit_sha": "abc"} deps = _make_deps(task=task_svc, journal=journal_svc, git=git_svc) c = Choreographer(deps) - env = await c.cell_pm_complete(pm_id, parent_id, "done") + env = await c.cell_pm_complete(pm_id, parent_id, "cell scope reviewed and approved") assert env.error is None task_svc.cell_pm_complete.assert_awaited_once() @@ -138,10 +155,13 @@ async def test_main_pm_complete_blocks_when_subtask_pending() -> None: task_svc.get_subtasks.return_value = [sub] journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.main_pm_complete(pm_id, root_id, "ship it") + env = await c.main_pm_complete( + pm_id, root_id, "root scope ready to ship to production" + ) body = env.as_dict() assert body["error"] == "tracing_gap" assert str(sub_id) in body["remediate"] diff --git a/tests/unit/gateway/test_choreographer_delegate_guards.py b/tests/unit/gateway/test_choreographer_delegate_guards.py index 79b74d34..dcc11348 100644 --- a/tests/unit/gateway/test_choreographer_delegate_guards.py +++ b/tests/unit/gateway/test_choreographer_delegate_guards.py @@ -54,6 +54,8 @@ def _delegate_inputs() -> DelegateInputs: assigned_to="be-dev-1", team="backend", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ) @@ -77,8 +79,12 @@ async def test_delegate_blocks_when_parent_not_in_progress() -> None: env = await c.delegate(pm_id, parent_id, _delegate_inputs()) body = env.as_dict() + # The spec's create_subtask gate requires the parent in_progress. + # Per Section 6 of the design doc, rejection messages now come from + # the spec — the spec rejects with 'invalid_state' citing the + # source-status set, not the gateway-specific i_will_plan hint. assert body["error"] == "invalid_state" - assert "i_will_plan" in body["remediate"] + assert "in_progress" in body["message"] task_svc.create_subtask.assert_not_awaited() diff --git a/tests/unit/gateway/test_choreographer_dev.py b/tests/unit/gateway/test_choreographer_dev.py index dfe85090..83ae8e7b 100644 --- a/tests/unit/gateway/test_choreographer_dev.py +++ b/tests/unit/gateway/test_choreographer_dev.py @@ -11,6 +11,17 @@ from roboco.services.gateway.choreographer import Choreographer, ChoreographerDe def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps: task = overrides.get("task", AsyncMock()) + # VerbRunner uses task.session.begin_nested() as a savepoint context + # manager. AsyncMock auto-attributes any access (so hasattr always + # returns True); we always overwrite session to a MagicMock with the + # correct async-context-manager protocol. + task.session = MagicMock() + task.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) work_session = overrides.get("work_session", AsyncMock()) git = overrides.get("git", AsyncMock()) a2a = overrides.get("a2a", AsyncMock()) @@ -97,13 +108,19 @@ async def test_i_will_work_on_pending_with_plan() -> None: parent_task_id=None, sequence=0, task_type="code", + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + quick_context=None, ) in_progress_task = MagicMock( id=task_id, status="in_progress", plan={"text": "do x"}, assigned_to=agent_id ) task_svc = AsyncMock() task_svc.get.return_value = pending_task - task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] @@ -138,10 +155,16 @@ async def test_i_will_work_on_pending_no_plan_returns_tracing_gap() -> None: parent_task_id=None, sequence=0, task_type="code", + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + quick_context=None, ) task_svc = AsyncMock() task_svc.get.return_value = pending_task - task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] @@ -155,28 +178,53 @@ async def test_i_will_work_on_pending_no_plan_returns_tracing_gap() -> None: body = env.as_dict() assert body["error"] == "tracing_gap" assert "plan" in body["missing"] - assert "i_will_work_on" in body["remediate"] @pytest.mark.asyncio async def test_i_will_work_on_needs_revision_re_starts() -> None: + """needs_revision dev path: spec composes (claim, set_plan, start), so + claim now runs even when the task is already assigned to the dev (the + spec source-status for claim includes NEEDS_REVISION). Migration + behavior change vs. the pre-spec verb body, which skipped claim if + already assigned.""" agent_id = uuid4() task_id = uuid4() nr_task = MagicMock( - id=task_id, status="needs_revision", assigned_to=agent_id, plan={"x": 1} + id=task_id, + status="needs_revision", + assigned_to=agent_id, + plan={"x": 1}, + task_type="code", + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + quick_context=None, + parent_task_id=None, + sequence=0, + team="backend", + ) + claimed = MagicMock( + id=task_id, status="claimed", assigned_to=agent_id, plan={"x": 1} ) in_progress_task = MagicMock( id=task_id, status="in_progress", assigned_to=agent_id, plan={"x": 1} ) task_svc = AsyncMock() task_svc.get.return_value = nr_task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] + task_svc.claim.return_value = claimed + task_svc.set_plan.return_value = claimed task_svc.start.return_value = in_progress_task deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on(agent_id, task_id) assert env.status == "in_progress" - task_svc.claim.assert_not_awaited() # already assigned task_svc.start.assert_awaited_once_with(task_id, agent_id) @@ -196,20 +244,101 @@ async def test_i_will_work_on_task_not_found_returns_not_found() -> None: @pytest.mark.asyncio async def test_i_will_work_on_invalid_state_returns_invalid_state() -> None: + """Completed task: spec rejects via can_invoke_action on the first + composed action (claim) — completed is not in claim's source_statuses, + so the message comes from the spec, not the verb body.""" agent_id = uuid4() task_id = uuid4() - completed_task = MagicMock(id=task_id, status="completed", assigned_to=agent_id) + completed_task = MagicMock( + id=task_id, + status="completed", + assigned_to=agent_id, + task_type="code", + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + quick_context=None, + parent_task_id=None, + sequence=0, + team="backend", + ) task_svc = AsyncMock() task_svc.get.return_value = completed_task + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on(agent_id, task_id) body = env.as_dict() assert body["error"] == "invalid_state" + # Spec produces "task is in 'completed', 'claim' requires: ..." assert "completed" in body["message"] +@pytest.mark.asyncio +async def test_i_will_work_on_blocks_when_journal_note_at_claim_missing() -> None: + """Pre-gateway parity P1: i_will_work_on requires a journal:note at claim. + + The composed (claim, set_plan, start) sequence runs first — the claim + sticks. Then the post-claim tracing gate fires because no journal:note + exists for (agent, task), and the agent gets a tracing_gap with a + remediation hint to write a note and retry. + """ + agent_id = uuid4() + task_id = uuid4() + pending_task = MagicMock( + id=task_id, + status="pending", + plan=None, + assigned_to=None, + parent_task_id=None, + sequence=0, + task_type="code", + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + quick_context=None, + ) + in_progress_task = MagicMock( + id=task_id, + status="in_progress", + plan={"text": "do x"}, + assigned_to=agent_id, + ) + task_svc = AsyncMock() + # `get` is called twice: once at verb entry, once by _post_claim_journal_gate. + task_svc.get.side_effect = [pending_task, in_progress_task] + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] + task_svc.claim.return_value = MagicMock( + id=task_id, status="claimed", plan=None, assigned_to=agent_id + ) + task_svc.set_plan.return_value = MagicMock( + id=task_id, status="claimed", plan={"text": "do x"}, assigned_to=agent_id + ) + task_svc.start.return_value = in_progress_task + journal_svc = AsyncMock() + journal_svc.has_note_for_task.return_value = False + deps = _make_deps(task=task_svc, journal=journal_svc) + c = Choreographer(deps) + + env = await c.i_will_work_on(agent_id, task_id, plan="do x then y") + body = env.as_dict() + assert body["error"] == "tracing_gap" + assert "journal:note_at_claim" in body["missing"] + assert "note(scope='note'" in body["remediate"] + # The composed action ran — claim+set_plan+start were called even though + # the post-claim gate failed. + task_svc.claim.assert_awaited_once_with(task_id, agent_id) + task_svc.start.assert_awaited_once_with(task_id, agent_id) + + # test_i_am_done_with_catchup_full_chain removed (audit P2-5/D-16): # i_am_done_with_catchup verb deleted. submit_for_qa now does push + PR # explicitly; i_am_done auto-runs submit_verification + submit_qa. @@ -217,6 +346,13 @@ async def test_i_will_work_on_invalid_state_returns_invalid_state() -> None: @pytest.mark.asyncio async def test_i_am_done_blocks_when_acceptance_criteria_unaddressed() -> None: + """Without a reflect note, unaddressed criteria block i_am_done. + + The reflect note is treated as the addressing artifact for any + criterion not explicitly cited via acceptance_criteria_status — + so this test deliberately omits reflect to surface the criterion + rejection. + """ agent_id = uuid4() task_id = uuid4() t = MagicMock( @@ -232,14 +368,29 @@ async def test_i_am_done_blocks_when_acceptance_criteria_unaddressed() -> None: acceptance_criteria_status=[ {"criterion": "AC1", "referencing_artifact_id": "c1"} ], - commits=[], + # Spec's PRECONDITION_COMMITS now runs before the tracing gate; + # supply a commit so the tracing gap (acceptance criteria) is + # the load-bearing rejection. + commits=[{"sha": "abc"}], + pr_number=8, + pr_url="https://x/pr/8", + team="backend", documents=[], dev_notes="", ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) journal_svc = AsyncMock() - journal_svc.has_reflect_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = False + # JOURNAL_DURING_WORK_AT_LEAST_ONE is satisfied so the load-bearing + # rejection here is the unaddressed AC2 criterion, not the new + # mid-flight cadence gate. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -249,8 +400,64 @@ async def test_i_am_done_blocks_when_acceptance_criteria_unaddressed() -> None: assert any("AC2" in m for m in body["missing"]) +@pytest.mark.asyncio +async def test_i_am_done_reflect_note_addresses_acceptance_criteria() -> None: + """A reflect note clears the acceptance-criteria gate. + + Per `_check_acceptance_criteria`, the reflect note is the agent's + attestation that the work meets every criterion; once it's present, + unaddressed criteria no longer block the submission. The other + tracing requirements (commits, PR, progress) still apply. + """ + agent_id = uuid4() + task_id = uuid4() + t = MagicMock( + id=task_id, + status="in_progress", + assigned_to=agent_id, + plan={"x": 1}, + branch_name="feature/backend/abc", + work_session_id=uuid4(), + self_verified=False, + progress_updates=[{"message": "p"}], + acceptance_criteria=["AC1", "AC2"], + acceptance_criteria_status=[], # nothing cited explicitly + commits=[{"sha": "abc"}], + pr_number=8, + pr_url="https://x/pr/8", + team="backend", + documents=[], + dev_notes="", + qa_notes="", + ) + task_svc = AsyncMock() + task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) + task_svc.submit_verification.return_value = t + task_svc.submit_for_qa.return_value = t + journal_svc = AsyncMock() + journal_svc.has_reflect_for_task.return_value = True + # Satisfy JOURNAL_DURING_WORK_AT_LEAST_ONE so the test's narrow assertion + # (criteria-gap cleared) isn't masked by an unrelated tracing failure. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False + deps = _make_deps(task=task_svc, journal=journal_svc) + c = Choreographer(deps) + + env = await c.i_am_done(agent_id, task_id, "done") + body = env.as_dict() + # criteria gate is cleared by reflect; if anything else fails, it + # must NOT be the AC2 criterion. + if body.get("error") == "tracing_gap": + assert not any("AC2" in m for m in body.get("missing", [])) + + @pytest.mark.asyncio async def test_i_am_done_blocks_when_journal_reflect_missing() -> None: + """Tracing-gate (journal:reflect) fires after the spec gate accepts.""" agent_id = uuid4() task_id = uuid4() t = MagicMock( @@ -266,14 +473,27 @@ async def test_i_am_done_blocks_when_journal_reflect_missing() -> None: acceptance_criteria_status=[ {"criterion": "AC1", "referencing_artifact_id": "c1"} ], - commits=[], + # Spec's PRECONDITION_COMMITS runs before the tracing gate; supply + # a commit so the missing journal:reflect is the load-bearing gap. + commits=[{"sha": "abc"}], + pr_number=8, + pr_url="https://x/pr/8", + team="backend", documents=[], dev_notes="", ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = False # no reflect + # Satisfy JOURNAL_DURING_WORK_AT_LEAST_ONE so journal:reflect is the + # load-bearing gap surfaced to the assertion. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -284,19 +504,36 @@ async def test_i_am_done_blocks_when_journal_reflect_missing() -> None: @pytest.mark.asyncio -async def test_i_am_done_not_assigned_returns_not_authorized() -> None: +async def test_i_am_done_not_assigned_returns_tracing_gap() -> None: + """Spec's PRECONDITION_OWNERSHIP rejects with tracing_gap (owns_task). + + Pre-spec migration the verb returned not_authorized via an inline + ownership check; that's now driven by the spec's extra precondition + so the rejection_kind is tracing_gap. + """ agent_id = uuid4() other_agent = uuid4() task_id = uuid4() - t = MagicMock(id=task_id, status="in_progress", assigned_to=other_agent) + t = MagicMock( + id=task_id, + status="in_progress", + assigned_to=other_agent, + commits=[{"sha": "abc"}], + team="backend", + quick_context=None, + ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_am_done(agent_id, task_id, "x") body = env.as_dict() - assert body["error"] == "not_authorized" + assert body["error"] == "tracing_gap" + assert "owns_task" in body["missing"] @pytest.mark.asyncio @@ -326,11 +563,19 @@ async def test_i_am_blocked_escalates_and_journals() -> None: agent_id = uuid4() task_id = uuid4() t = MagicMock( - id=task_id, status="in_progress", assigned_to=agent_id, pre_block_state=None + id=task_id, + status="in_progress", + assigned_to=agent_id, + pre_block_state=None, + task_type="code", + team="backend", ) - after = MagicMock(id=task_id, status="blocked") + after = MagicMock(id=task_id, status="blocked", assigned_to=agent_id) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) task_svc.escalate.return_value = after journal_svc = AsyncMock() deps = _make_deps(task=task_svc, journal=journal_svc) diff --git a/tests/unit/gateway/test_choreographer_doc.py b/tests/unit/gateway/test_choreographer_doc.py index b9d23458..25d55df9 100644 --- a/tests/unit/gateway/test_choreographer_doc.py +++ b/tests/unit/gateway/test_choreographer_doc.py @@ -79,14 +79,25 @@ async def test_claim_doc_task_returns_evidence() -> None: async def test_claim_doc_task_blocks_wrong_state() -> None: doc_id = uuid4() task_id = uuid4() - t = MagicMock(id=task_id, status="in_progress") + t = MagicMock( + id=task_id, + status="in_progress", + task_type="code", + team="backend", + quick_context=None, + ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=doc_id, role="documenter", team="backend", slug=None + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.claim_doc_task(doc_id, task_id) body = env.as_dict() + # Spec rejects: in_progress is not in `claim` action's source_statuses + # (PENDING, NEEDS_REVISION, AWAITING_QA, AWAITING_DOCUMENTATION). assert body["error"] == "invalid_state" @@ -103,20 +114,46 @@ async def test_claim_doc_task_not_found() -> None: assert env.as_dict()["error"] == "not_found" +def _doc_owned_task(task_id: Any, doc_id: Any, **overrides: Any) -> MagicMock: + """Build a doc-owned awaiting_documentation task fixture for the spec gate. + + Status defaults to awaiting_documentation (which matches docs_complete's + spec source_statuses). task_type / team / quick_context defaulted so + the spec gate evaluates against real values. + """ + base = { + "id": task_id, + "status": "awaiting_documentation", + "task_type": "code", + "team": "backend", + "assigned_to": doc_id, + "quick_context": None, + } + base.update(overrides) + return MagicMock(**base) + + +def _doc_agent_mock(doc_id: Any) -> MagicMock: + return MagicMock(id=doc_id, role="documenter", team="backend", slug=None) + + @pytest.mark.asyncio async def test_i_documented_requires_min_notes() -> None: doc_id = uuid4() task_id = uuid4() - t = MagicMock(id=task_id, status="claimed", assigned_to=doc_id) + t = _doc_owned_task(task_id, doc_id) task_svc = AsyncMock() task_svc.get.return_value = t - deps = _make_deps(task=task_svc) + task_svc.agent_for.return_value = _doc_agent_mock(doc_id) + journal_svc = AsyncMock() + journal_svc.has_reflect_for_task.return_value = True + deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) env = await c.i_documented(doc_id, task_id, notes="short", files=["a.md"]) body = env.as_dict() assert body["error"] == "tracing_gap" - assert "docs_notes>=20" in body["missing"] + assert "docs_notes>=min" in body["missing"] @pytest.mark.asyncio @@ -136,31 +173,50 @@ async def test_i_documented_task_not_found() -> None: async def test_i_documented_requires_files() -> None: doc_id = uuid4() task_id = uuid4() - t = MagicMock(id=task_id, status="claimed", assigned_to=doc_id) + t = _doc_owned_task(task_id, doc_id) task_svc = AsyncMock() task_svc.get.return_value = t - deps = _make_deps(task=task_svc) + task_svc.agent_for.return_value = _doc_agent_mock(doc_id) + journal_svc = AsyncMock() + journal_svc.has_reflect_for_task.return_value = True + deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) notes = "Wrote backend/guides/feature-x.md with usage examples." env = await c.i_documented(doc_id, task_id, notes=notes, files=[]) body = env.as_dict() assert body["error"] == "tracing_gap" - assert "files" in body["missing"] + assert "docs_files_non_empty" in body["missing"] @pytest.mark.asyncio async def test_i_documented_succeeds_and_transitions() -> None: doc_id = uuid4() task_id = uuid4() - t = MagicMock(id=task_id, status="claimed", assigned_to=doc_id, team="backend") - after = MagicMock(**{**t.__dict__, "status": "awaiting_pm_review"}) + t = _doc_owned_task(task_id, doc_id) + after = MagicMock( + id=task_id, + status="awaiting_pm_review", + assigned_to=doc_id, + team="backend", + ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _doc_agent_mock(doc_id) task_svc.docs_complete.return_value = after task_svc.cell_pm_for_team.return_value = MagicMock(id=uuid4()) + task_svc.session = MagicMock() + task_svc.session.flush = AsyncMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) a2a_svc = AsyncMock() - deps = _make_deps(task=task_svc, a2a=a2a_svc) + journal_svc = AsyncMock() + journal_svc.has_reflect_for_task.return_value = True + deps = _make_deps(task=task_svc, a2a=a2a_svc, journal=journal_svc) c = Choreographer(deps) notes = "Wrote backend/guides/feature-x.md with usage examples and config notes." @@ -177,9 +233,10 @@ async def test_i_documented_not_assigned_returns_not_authorized() -> None: doc_id = uuid4() other = uuid4() task_id = uuid4() - t = MagicMock(id=task_id, status="claimed", assigned_to=other) + t = _doc_owned_task(task_id, other) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _doc_agent_mock(doc_id) deps = _make_deps(task=task_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_choreographer_impl_branches.py b/tests/unit/gateway/test_choreographer_impl_branches.py index aad87629..f10432a8 100644 --- a/tests/unit/gateway/test_choreographer_impl_branches.py +++ b/tests/unit/gateway/test_choreographer_impl_branches.py @@ -15,7 +15,6 @@ import pytest import structlog from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps from roboco.services.gateway.choreographer._impl import DelegateInputs -from roboco.services.gateway.claim_guards import pm_cannot_execute_code_guard from roboco.services.gateway.envelope import Envelope @@ -26,6 +25,8 @@ def _wire_dev_task_svc( Defaults `agent_for` → developer/backend and the three list-* methods to empty lists so claim-guard short-circuits never fire unintentionally. + Also wires ``session.begin_nested()`` so VerbRunner's savepoint context + manager works against the mock. """ task_svc = AsyncMock() task_svc.get.return_value = MagicMock( @@ -37,11 +38,24 @@ def _wire_dev_task_svc( task_type="code", parent_task_id=parent_task_id, team="backend", + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + quick_context=None, + ) + task_svc.agent_for.return_value = MagicMock( + role="developer", team="backend", slug=None ) - task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) return task_svc @@ -56,6 +70,19 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "evidence_repo": AsyncMock(), } base.update(overrides) + # VerbRunner wraps composed atomic actions in + # ``task.session.begin_nested()``. AsyncMock auto-attribute access + # would return an unawaitable coroutine, breaking the + # ``async with`` protocol. Overwrite session with a MagicMock that + # implements the async-context-manager protocol explicitly. + task_dep = base["task"] + task_dep.session = MagicMock() + task_dep.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) repo = base["evidence_repo"] for method in ( "list_unread_a2a", @@ -92,6 +119,12 @@ async def test_emit_rejection_passes_through_ok_envelope() -> None: @pytest.mark.asyncio async def test_i_will_work_on_pending_claim_raises_returns_invalid_state() -> None: + """When the runner re-raises a RuntimeError from claim(), the verb body + catches it and surfaces an invalid_state envelope with the runner's + message. Pre-spec the verb body produced "claim failed during + finalization"; the spec-driven body produces "verb runner failed: + " so the agent still gets a remediation hint instead of a 500. + """ agent_id = uuid4() task_id = uuid4() task_svc = _wire_dev_task_svc(task_id, status="pending") @@ -101,7 +134,8 @@ async def test_i_will_work_on_pending_claim_raises_returns_invalid_state() -> No env = await c.i_will_work_on(agent_id, task_id, plan="plan") body = env.as_dict() assert body["error"] == "invalid_state" - assert "claim failed during finalization" in body["message"] + assert "verb runner failed" in body["message"] + assert "workspace down" in body["message"] @pytest.mark.asyncio @@ -251,14 +285,6 @@ async def test_i_will_work_on_in_progress_assigned_to_self_idempotent() -> None: # --------------------------------------------------------------------------- -def test_pm_cannot_execute_code_guard_passes_for_non_code_task() -> None: - """Direct guard unit test: PM + non-code task → no rejection. - Covers claim_guards.py:98 (the early-return for non-code task_type). - """ - assert pm_cannot_execute_code_guard("cell_pm", "planning") is None - assert pm_cannot_execute_code_guard("main_pm", "documentation") is None - - @pytest.mark.asyncio async def test_i_will_plan_pm_with_already_active_task_rejects() -> None: """The already_active_guard still fires on i_will_plan even though @@ -389,11 +415,13 @@ async def test_delegate_parent_not_found() -> None: pm_id, parent_id, DelegateInputs( - title="x", - description="y", + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", assigned_to="be-dev-1", team="backend", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ), ) body = env.as_dict() @@ -424,11 +452,13 @@ async def test_delegate_unknown_role_rejected() -> None: pm_id, parent_id, DelegateInputs( - title="x", - description="y", + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", assigned_to="be-dev-1", team="backend", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ), ) body = env.as_dict() @@ -460,11 +490,13 @@ async def test_delegate_parent_no_project_rejected() -> None: pm_id, parent_id, DelegateInputs( - title="x", - description="y", + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", assigned_to="be-dev-1", team="backend", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ), ) body = env.as_dict() @@ -941,7 +973,11 @@ def test_resolve_skill_string_entries() -> None: @pytest.mark.asyncio async def test_i_will_plan_pending_claim_returns_none_emit_rejection() -> None: - """Forces the await self._emit_rejection in the failed-claim branch.""" + """When claim() returns None inside the runner, the savepoint rolls + back and the runner-failure path surfaces as invalid_state. Pre-spec + this branched into a hand-rolled "claim failed" message; now it is + emitted via _claim_plan_start_run's exception handler. + """ pm_id = uuid4() task_id = uuid4() task = MagicMock( @@ -953,10 +989,13 @@ async def test_i_will_plan_pending_claim_returns_none_emit_rejection() -> None: team="backend", parent_task_id=None, task_type="planning", + quick_context=None, ) task_svc = AsyncMock() task_svc.get.return_value = task - task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend") + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role="cell_pm", team="backend", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] @@ -966,7 +1005,7 @@ async def test_i_will_plan_pending_claim_returns_none_emit_rejection() -> None: env = await c.i_will_plan(pm_id, task_id, plan="my plan that is long enough") body = env.as_dict() assert body["error"] == "invalid_state" - assert "claim failed" in body["message"] + assert "verb runner failed" in body["message"] # --------------------------------------------------------------------------- @@ -1027,7 +1066,10 @@ async def test_i_will_work_on_envelope_carries_introspection_on_success() -> Non assert body["error"] is None assert body["current_state"] == "in_progress" assert isinstance(body["valid_next_verbs"], list) - assert "commit" in body["valid_next_verbs"] + # `valid_next_verbs` lists lifecycle INTENT verbs; `commit` is a + # content tool (do_server), not an intent, so the canonical spec + # excludes it. `open_pr` and `i_am_done` are the in_progress intents. + assert "open_pr" in body["valid_next_verbs"] assert "i_am_done" in body["valid_next_verbs"] @@ -1037,9 +1079,7 @@ async def test_i_will_work_on_envelope_carries_introspection_on_rejection() -> N so the agent learns what verbs are actually valid right now.""" agent_id = uuid4() task_id = uuid4() - task_svc = _wire_dev_task_svc( - task_id, status="completed", assigned_to=agent_id - ) + task_svc = _wire_dev_task_svc(task_id, status="completed", assigned_to=agent_id) deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_work_on(agent_id, task_id, plan="x") @@ -1080,8 +1120,11 @@ async def test_open_pr_does_not_create_pr_if_no_commits() -> None: c = Choreographer(deps) env = await c.open_pr(dev_id, task_id) body = env.as_dict() - assert body["error"] == "invalid_state" - assert "no commits" in body["message"] + # Spec's PRECONDITION_COMMITS now produces tracing_gap rather than the + # previous bespoke invalid_state. The atomicity invariant the test + # pins (no git side effect when commits=[]) is unchanged. + assert body["error"] == "tracing_gap" + assert body["missing"] == ["commits>=1"] git_svc.create_pr.assert_not_called() git_svc.push_branch.assert_not_called() @@ -1104,8 +1147,9 @@ async def test_i_will_work_on_missing_plan_does_not_claim_pending_task() -> None body = env.as_dict() assert body["error"] == "tracing_gap" assert "plan" in body["missing"] - task_svc.claim.assert_not_called(), ( - "claim() ran before plan precondition was satisfied — atomicity broken" + ( + task_svc.claim.assert_not_called(), + ("claim() ran before plan precondition was satisfied — atomicity broken"), ) diff --git a/tests/unit/gateway/test_choreographer_pm.py b/tests/unit/gateway/test_choreographer_pm.py index 8321d9ed..2c27f977 100644 --- a/tests/unit/gateway/test_choreographer_pm.py +++ b/tests/unit/gateway/test_choreographer_pm.py @@ -257,6 +257,7 @@ async def test_cell_pm_complete_merges_then_completes() -> None: git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "merge-abc"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) @@ -283,10 +284,13 @@ async def test_cell_pm_complete_blocks_if_subtasks_unfinished() -> None: task_svc.all_subtasks_terminal.return_value = False journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.cell_pm_complete(pm_id, task_id, notes="x") + env = await c.cell_pm_complete( + pm_id, task_id, notes="reviewed cell scope and approved merge" + ) body = env.as_dict() assert body["error"] == "tracing_gap" assert "subtasks" in str(body["missing"]).lower() @@ -333,10 +337,13 @@ async def test_cell_pm_complete_no_pr_returns_invalid_state() -> None: task_svc.all_subtasks_terminal.return_value = True journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.cell_pm_complete(pm_id, task_id, notes="x") + env = await c.cell_pm_complete( + pm_id, task_id, notes="reviewed cell scope and approved merge" + ) assert env.as_dict()["error"] == "invalid_state" @@ -377,10 +384,13 @@ async def test_main_pm_complete_opens_master_pr_and_escalates() -> None: git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "https://x/y/pull/99"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.main_pm_complete(main_pm_id, root_task_id, notes="ready for prod") + env = await c.main_pm_complete( + main_pm_id, root_task_id, notes="root scope reviewed and ready for production" + ) assert env.error is None assert env.status == "awaiting_ceo_approval" git_svc.create_pr.assert_awaited_once_with( @@ -413,10 +423,13 @@ async def test_main_pm_complete_skips_pr_creation_if_already_master_targeted() - git_svc.pr_target.return_value = "master" journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) - await c.main_pm_complete(main_pm_id, root_task_id, notes="ready") + await c.main_pm_complete( + main_pm_id, root_task_id, notes="root scope reviewed and ready" + ) git_svc.create_pr.assert_not_awaited() task_svc.escalate_to_ceo.assert_awaited_once() @@ -489,10 +502,13 @@ async def test_complete_dispatches_cell_pm() -> None: git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "x"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.complete(pm_id, task_id, notes="ok") + env = await c.complete( + pm_id, task_id, notes="cell scope reviewed and approved for merge" + ) assert env.status == "completed" task_svc.cell_pm_complete.assert_awaited_once() @@ -520,10 +536,13 @@ async def test_complete_dispatches_main_pm() -> None: git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "x"} journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = True + journal_svc.has_reflect_for_task.return_value = True deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.complete(main_pm_id, root_task_id, notes="ready") + env = await c.complete( + main_pm_id, root_task_id, notes="root scope reviewed and ready" + ) assert env.status == "awaiting_ceo_approval" task_svc.escalate_to_ceo.assert_awaited_once() @@ -601,6 +620,12 @@ async def test_escalate_up_blocks_without_journal_decision() -> None: t = MagicMock(id=task_id, status="blocked") task_svc = AsyncMock() task_svc.get.return_value = t + # Spec gate runs before the journal:decision preflight; provide a + # valid PM role so the gate passes and the preflight is the + # load-bearing rejector. + task_svc.agent_for.return_value = MagicMock( + role="cell_pm", escalation_target="main-pm" + ) journal_svc = AsyncMock() journal_svc.has_decision_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) @@ -614,13 +639,20 @@ async def test_escalate_up_blocks_without_journal_decision() -> None: @pytest.mark.asyncio async def test_escalate_up_no_target_returns_invalid_state() -> None: + """Verb-specific preflight: PM whose escalation_target is unconfigured. + + The spec allows cell_pm/main_pm to call escalate_up regardless of + target slug presence (target metadata lives on the agent record, not + the lifecycle). The verb body's preflight is what surfaces the + invalid_state when no target is configured. + """ pm_id = uuid4() task_id = uuid4() t = MagicMock(id=task_id, status="blocked") task_svc = AsyncMock() task_svc.get.return_value = t task_svc.agent_for.return_value = MagicMock( - role="auditor", + role="cell_pm", escalation_target=None, ) journal_svc = AsyncMock() diff --git a/tests/unit/gateway/test_choreographer_pm_extras.py b/tests/unit/gateway/test_choreographer_pm_extras.py index 8e79e3b7..9f63b104 100644 --- a/tests/unit/gateway/test_choreographer_pm_extras.py +++ b/tests/unit/gateway/test_choreographer_pm_extras.py @@ -30,6 +30,19 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "evidence_repo": AsyncMock(), } base.update(overrides) + # VerbRunner wraps composed atomic actions in + # ``task.session.begin_nested()``. AsyncMock auto-attribute access + # would return an unawaitable coroutine, breaking the + # ``async with`` protocol. Overwrite session with a MagicMock that + # implements the async-context-manager protocol explicitly. + task = base["task"] + task.session = MagicMock() + task.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) repo = base["evidence_repo"] for method in ( "list_unread_a2a", @@ -78,9 +91,12 @@ async def test_i_will_plan_claims_starts_and_sets_plan() -> None: ) task_svc = AsyncMock() task_svc.get.return_value = pending - task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend") + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role="cell_pm", team="backend", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] task_svc.claim.return_value = claimed task_svc.set_plan.return_value = claimed task_svc.start.return_value = started @@ -95,6 +111,65 @@ async def test_i_will_plan_claims_starts_and_sets_plan() -> None: task_svc.start.assert_awaited_once_with(task_id, pm_id) +@pytest.mark.asyncio +async def test_i_will_plan_blocks_when_journal_decision_at_claim_missing() -> None: + """Pre-gateway parity P3: i_will_plan requires a journal:decision at claim. + + The composed (claim, set_plan, start) sequence runs first — the + claim sticks. Then the post-claim tracing gate fires because no + journal:decision exists for (PM, task), and the agent gets a + tracing_gap pointing at note(scope='decision', ...). + """ + pm_id = uuid4() + task_id = uuid4() + pending = MagicMock( + id=task_id, + status="pending", + plan=None, + assigned_to=None, + task_type="planning", + parent_task_id=None, + sequence=0, + ) + started = MagicMock( + id=task_id, + status="in_progress", + plan={"text": "x"}, + assigned_to=pm_id, + task_type="planning", + ) + task_svc = AsyncMock() + # `get` is called twice: at verb entry and inside _post_claim_journal_gate. + task_svc.get.side_effect = [pending, started] + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role="cell_pm", team="backend", slug=None + ) + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] + task_svc.claim.return_value = MagicMock( + id=task_id, status="claimed", plan=None, assigned_to=pm_id + ) + task_svc.set_plan.return_value = MagicMock( + id=task_id, status="claimed", plan={"text": "x"}, assigned_to=pm_id + ) + task_svc.start.return_value = started + journal_svc = AsyncMock() + journal_svc.has_decision_for_task.return_value = False + deps = _make_deps(task=task_svc, journal=journal_svc) + c = Choreographer(deps) + + env = await c.i_will_plan(pm_id, task_id, plan="break the work into 3 subtasks") + body = env.as_dict() + assert body["error"] == "tracing_gap" + assert "journal:decision_at_claim" in body["missing"] + assert "note(scope='decision'" in body["remediate"] + # The composed action ran — claim+set_plan+start fired even though + # the post-claim gate failed. + task_svc.claim.assert_awaited_once_with(task_id, pm_id) + task_svc.start.assert_awaited_once_with(task_id, pm_id) + + @pytest.mark.asyncio async def test_i_will_plan_rejects_non_pm_role() -> None: pm_id = uuid4() @@ -160,9 +235,12 @@ async def test_i_will_plan_calls_claim_when_pre_assigned_and_pending() -> None: ) task_svc = AsyncMock() task_svc.get.return_value = pending_pre_assigned - task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm") + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role="main_pm", team="main_pm", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] task_svc.claim.return_value = claimed task_svc.set_plan.return_value = claimed task_svc.start.return_value = started @@ -207,9 +285,12 @@ async def test_i_will_plan_surfaces_start_failure_instead_of_faking_ok() -> None ) task_svc = AsyncMock() task_svc.get.return_value = pending - task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm") + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role="main_pm", team="main_pm", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] task_svc.claim.return_value = claimed task_svc.set_plan.return_value = claimed task_svc.start.return_value = None # the bug: start fails silently @@ -260,8 +341,14 @@ async def test_i_will_plan_idempotent_when_already_in_progress_for_caller() -> N @pytest.mark.asyncio -async def test_i_will_plan_idempotent_when_already_claimed_for_caller() -> None: - """Same regression but task is in claimed (post-claim, pre-start).""" +async def test_i_will_plan_recovery_when_already_claimed_for_caller() -> None: + """Same regression but task is in claimed (post-claim, pre-start). + + Recovery semantics (Task 12 spec migration): when the caller already + owns the task in `claimed`, the verb runs only set_plan + start + (skipping re-claim, which the spec gate would reject because CLAIMED + is not a source state for `claim`). End state is `in_progress`. + """ pm_id = uuid4() task_id = uuid4() claimed = MagicMock( @@ -273,19 +360,35 @@ async def test_i_will_plan_idempotent_when_already_claimed_for_caller() -> None: parent_task_id=None, sequence=0, ) + started = MagicMock( + id=task_id, + status="in_progress", + plan="re-entry plan", + assigned_to=pm_id, + task_type="planning", + ) task_svc = AsyncMock() task_svc.get.return_value = claimed - task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm") + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role="main_pm", team="main_pm", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] + task_svc.set_plan.return_value = claimed + task_svc.start.return_value = started deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_will_plan(pm_id, task_id, plan="re-entry plan") assert env.error is None - assert env.status == "claimed" + assert env.status == "in_progress" task_svc.heartbeat.assert_awaited() + # Recovery does NOT re-call claim — CLAIMED is not a claim source state. + task_svc.claim.assert_not_awaited() + # Recovery DOES call start to push claimed → in_progress. + task_svc.start.assert_awaited_once_with(task_id, pm_id) @pytest.mark.asyncio @@ -380,6 +483,8 @@ async def test_delegate_main_pm_to_cell_pm_creates_subtask() -> None: assigned_to="be-pm", team="backend", task_type="planning", + nature="technical", + acceptance_criteria=["all backend subtasks defined with criteria"], ), ) assert env.error is None @@ -419,6 +524,8 @@ async def test_delegate_cell_pm_to_team_dev_creates_subtask() -> None: assigned_to="be-dev-1", team="backend", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ), ) assert env.error is None @@ -429,10 +536,16 @@ async def test_delegate_cell_pm_to_team_dev_creates_subtask() -> None: async def test_delegate_main_pm_to_dev_is_rejected() -> None: main_pm_id = uuid4() parent_id = uuid4() - parent = MagicMock(id=parent_id, project_id=uuid4()) + parent = MagicMock( + id=parent_id, + project_id=uuid4(), + status="in_progress", + assigned_to=main_pm_id, + ) task_svc = AsyncMock() task_svc.get.return_value = parent task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm") + task_svc.get_subtasks.return_value = [] deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -440,11 +553,13 @@ async def test_delegate_main_pm_to_dev_is_rejected() -> None: main_pm_id, parent_id, DelegateInputs( - title="x", - description="y", + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", assigned_to="be-dev-1", team="backend", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ), ) body = env.as_dict() @@ -456,10 +571,16 @@ async def test_delegate_main_pm_to_dev_is_rejected() -> None: async def test_delegate_cell_pm_to_other_pm_rejected() -> None: cell_pm_id = uuid4() parent_id = uuid4() - parent = MagicMock(id=parent_id, project_id=uuid4()) + parent = MagicMock( + id=parent_id, + project_id=uuid4(), + status="in_progress", + assigned_to=cell_pm_id, + ) 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 = [] deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -467,11 +588,13 @@ async def test_delegate_cell_pm_to_other_pm_rejected() -> None: cell_pm_id, parent_id, DelegateInputs( - title="x", - description="y", + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", assigned_to="be-pm", team="backend", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ), ) body = env.as_dict() @@ -482,10 +605,16 @@ async def test_delegate_cell_pm_to_other_pm_rejected() -> None: async def test_delegate_unknown_assignee_returns_invalid_state() -> None: pm_id = uuid4() parent_id = uuid4() - parent = MagicMock(id=parent_id, project_id=uuid4()) + parent = MagicMock( + id=parent_id, + project_id=uuid4(), + status="in_progress", + assigned_to=pm_id, + ) task_svc = AsyncMock() task_svc.get.return_value = parent task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm") + task_svc.get_subtasks.return_value = [] deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -493,11 +622,13 @@ async def test_delegate_unknown_assignee_returns_invalid_state() -> None: pm_id, parent_id, DelegateInputs( - title="x", - description="y", + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", assigned_to="nope-pm", team="backend", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ), ) body = env.as_dict() @@ -508,10 +639,16 @@ async def test_delegate_unknown_assignee_returns_invalid_state() -> None: async def test_delegate_invalid_team_enum_rejected() -> None: pm_id = uuid4() parent_id = uuid4() - parent = MagicMock(id=parent_id, project_id=uuid4()) + parent = MagicMock( + id=parent_id, + project_id=uuid4(), + status="in_progress", + assigned_to=pm_id, + ) 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 = [] deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -519,11 +656,13 @@ async def test_delegate_invalid_team_enum_rejected() -> None: pm_id, parent_id, DelegateInputs( - title="x", - description="y", + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", assigned_to="be-dev-1", team="not-a-team", task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], ), ) assert env.as_dict()["error"] == "invalid_state" @@ -781,10 +920,12 @@ async def test_delegate_main_pm_to_cell_pm_rejects_code_typed_subtask() -> None: parent_id, DelegateInputs( title="Backend slice", - description="Plan + drive backend work", + description="Plan + drive backend work end to end please", assigned_to="be-pm", team="backend", task_type="code", # WRONG — Cell PM should get planning + nature="technical", + acceptance_criteria=["all subtasks created with criteria"], ), ) body = env.as_dict() @@ -819,10 +960,12 @@ async def test_delegate_main_pm_to_cell_pm_accepts_planning_subtask() -> None: parent_id, DelegateInputs( title="Backend slice", - description="Plan + drive backend work", + description="Plan + drive backend work end to end please", assigned_to="be-pm", team="backend", task_type="planning", + nature="technical", + acceptance_criteria=["all subtasks created with criteria"], ), ) assert env.error is None diff --git a/tests/unit/gateway/test_choreographer_qa.py b/tests/unit/gateway/test_choreographer_qa.py index aa66b2cb..13b6cbdc 100644 --- a/tests/unit/gateway/test_choreographer_qa.py +++ b/tests/unit/gateway/test_choreographer_qa.py @@ -89,16 +89,27 @@ async def test_claim_review_returns_evidence_inline() -> None: async def test_claim_review_blocks_if_task_not_awaiting_qa() -> None: qa_id = uuid4() task_id = uuid4() - t = MagicMock(id=task_id, status="in_progress") + t = MagicMock( + id=task_id, + status="in_progress", + task_type="code", + team="backend", + quick_context=None, + ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=qa_id, role="qa", team="backend", slug=None + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.claim_review(qa_id, task_id) body = env.as_dict() + # Spec rejects: in_progress is not in `claim` action's source_statuses + # (PENDING, NEEDS_REVISION, AWAITING_QA, AWAITING_DOCUMENTATION). assert body["error"] == "invalid_state" - assert "awaiting_qa" in body["message"] + assert "in_progress" in body["message"] or "awaiting_qa" in body["message"] @pytest.mark.asyncio @@ -162,18 +173,39 @@ async def test_pass_review_task_not_found_returns_not_found() -> None: assert env.as_dict()["error"] == "not_found" +def _qa_owned_task(task_id: Any, qa_id: Any, **overrides: Any) -> MagicMock: + """Build a QA-owned awaiting_qa task fixture compatible with the spec gate. + + Status defaults to awaiting_qa (which matches qa_pass / qa_fail's + spec source_statuses). task_type / team / quick_context defaulted + so the spec gate's role/state/task_type checks all evaluate against + real values rather than auto-generated MagicMock attributes. + """ + base = { + "id": task_id, + "status": "awaiting_qa", + "task_type": "code", + "team": "backend", + "assigned_to": qa_id, + "qa_evidence_inspected": True, + "quick_context": None, + } + base.update(overrides) + return MagicMock(**base) + + +def _qa_agent_mock(qa_id: Any) -> MagicMock: + return MagicMock(id=qa_id, role="qa", team="backend", slug=None) + + @pytest.mark.asyncio async def test_pass_review_requires_qa_notes_min_chars() -> None: qa_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_owned_task(task_id, qa_id) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) @@ -189,14 +221,10 @@ async def test_pass_review_requires_qa_notes_min_chars() -> None: async def test_pass_review_requires_journal_learning() -> None: qa_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_owned_task(task_id, qa_id) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) @@ -213,14 +241,10 @@ async def test_pass_review_requires_journal_learning() -> None: async def test_pass_review_requires_evidence_inspected() -> None: qa_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=False, - ) + t = _qa_owned_task(task_id, qa_id, qa_evidence_inspected=False) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) @@ -237,24 +261,27 @@ async def test_pass_review_requires_evidence_inspected() -> None: async def test_pass_review_succeeds_and_transitions() -> None: qa_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_owned_task(task_id, qa_id) after = MagicMock( - **{ - **t.__dict__, - "status": "awaiting_documentation", - "team": "backend", - "pr_url": "https://x/pr/8", - }, + id=task_id, + status="awaiting_documentation", + assigned_to=qa_id, + team="backend", + pr_url="https://x/pr/8", + qa_evidence_inspected=True, ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) task_svc.qa_pass.return_value = after task_svc.documenter_for_team.return_value = MagicMock(id=uuid4()) + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = True a2a_svc = AsyncMock() @@ -277,11 +304,10 @@ async def test_pass_review_not_assigned_returns_not_authorized() -> None: qa_id = uuid4() other = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, status="claimed", assigned_to=other, qa_evidence_inspected=True - ) + t = _qa_owned_task(task_id, other) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -295,18 +321,24 @@ async def test_fail_review_succeeds() -> None: qa_id = uuid4() task_id = uuid4() dev_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_owned_task(task_id, qa_id) after = MagicMock( - **{**t.__dict__, "status": "needs_revision", "assigned_to": dev_id}, + id=task_id, + status="needs_revision", + assigned_to=dev_id, + team="backend", ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) task_svc.qa_fail.return_value = after + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = True a2a_svc = AsyncMock() @@ -328,14 +360,10 @@ async def test_fail_review_succeeds() -> None: async def test_fail_review_requires_at_least_one_issue() -> None: qa_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_owned_task(task_id, qa_id) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) @@ -352,11 +380,10 @@ async def test_fail_review_not_assigned_returns_not_authorized() -> None: qa_id = uuid4() other = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, status="claimed", assigned_to=other, qa_evidence_inspected=True - ) + t = _qa_owned_task(task_id, other) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -369,14 +396,10 @@ async def test_fail_review_not_assigned_returns_not_authorized() -> None: async def test_fail_review_blocks_when_journal_learning_missing() -> None: qa_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_owned_task(task_id, qa_id) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent_mock(qa_id) journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = False # no learning deps = _make_deps(task=task_svc, journal=journal_svc) diff --git a/tests/unit/gateway/test_choreographer_reassignment.py b/tests/unit/gateway/test_choreographer_reassignment.py index fe441a55..eca119dc 100644 --- a/tests/unit/gateway/test_choreographer_reassignment.py +++ b/tests/unit/gateway/test_choreographer_reassignment.py @@ -30,6 +30,19 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "evidence_repo": AsyncMock(), } base.update(overrides) + # VerbRunner uses task.session.begin_nested() as a savepoint context + # manager for spec-driven verbs (i_am_done, i_will_work_on, etc.). + # Other choreographer codepaths (doc.py:i_documented) await + # task.session.flush(), so keep the session itself an AsyncMock and + # only override begin_nested with a sync MagicMock that returns the + # async-context-manager protocol the runner expects. + task = base["task"] + task.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) repo = base["evidence_repo"] for method in ( "list_unread_a2a", @@ -78,6 +91,9 @@ async def test_i_am_done_reassigns_task_to_qa_agent() -> None: documents=[], dev_notes="", ) + after_verify = MagicMock( + **{**initial.__dict__, "status": "verifying", "self_verified": True}, + ) after_submit = MagicMock( **{**initial.__dict__, "status": "awaiting_qa", "assigned_to": None}, ) @@ -85,6 +101,10 @@ async def test_i_am_done_reassigns_task_to_qa_agent() -> None: task_svc = AsyncMock() task_svc.get.return_value = initial + task_svc.agent_for.return_value = MagicMock( + id=dev_id, role="developer", team="backend", slug=None + ) + task_svc.submit_verification.return_value = after_verify task_svc.submit_qa.return_value = after_submit task_svc.qa_agent_for_team.return_value = qa_agent @@ -94,6 +114,11 @@ async def test_i_am_done_reassigns_task_to_qa_agent() -> None: journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE: at least one decision/learning/struggle + # must exist between claim and submit. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, work_session=work_svc, journal=journal_svc) deps.evidence_repo.journal_highlights_for_task.return_value = [] @@ -130,12 +155,19 @@ async def test_i_am_done_skips_reassign_when_no_qa_agent() -> None: documents=[], dev_notes="", ) + after_verify = MagicMock( + **{**initial.__dict__, "status": "verifying", "self_verified": True}, + ) after_submit = MagicMock( **{**initial.__dict__, "status": "awaiting_qa", "assigned_to": None}, ) task_svc = AsyncMock() task_svc.get.return_value = initial + task_svc.agent_for.return_value = MagicMock( + id=dev_id, role="developer", team="backend", slug=None + ) + task_svc.submit_verification.return_value = after_verify task_svc.submit_qa.return_value = after_submit task_svc.qa_agent_for_team.return_value = None # no QA found @@ -145,6 +177,10 @@ async def test_i_am_done_skips_reassign_when_no_qa_agent() -> None: journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, work_session=work_svc, journal=journal_svc) deps.evidence_repo.journal_highlights_for_task.return_value = [] @@ -159,30 +195,51 @@ async def test_i_am_done_skips_reassign_when_no_qa_agent() -> None: # --------------------------------------------------------------------------- +def _qa_awaiting_task(task_id: Any, qa_id: Any) -> MagicMock: + return MagicMock( + id=task_id, + status="awaiting_qa", + task_type="code", + team="backend", + assigned_to=qa_id, + qa_evidence_inspected=True, + quick_context=None, + ) + + +def _qa_agent(qa_id: Any) -> MagicMock: + return MagicMock(id=qa_id, role="qa", team="backend", slug=None) + + +def _begin_nested_mock() -> Any: + return MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + + @pytest.mark.asyncio async def test_pass_review_reassigns_task_to_documenter() -> None: qa_id = uuid4() doc_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_awaiting_task(task_id, qa_id) after = MagicMock( - **{ - **t.__dict__, - "status": "awaiting_documentation", - "team": "backend", - "pr_url": "https://x/pr/8", - "assigned_to": None, - }, + id=task_id, + status="awaiting_documentation", + team="backend", + pr_url="https://x/pr/8", + assigned_to=None, ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent(qa_id) task_svc.qa_pass.return_value = after task_svc.documenter_for_team.return_value = MagicMock(id=doc_id) + task_svc.session = MagicMock() + task_svc.session.begin_nested = _begin_nested_mock() journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) @@ -201,25 +258,21 @@ async def test_pass_review_reassigns_task_to_documenter() -> None: async def test_pass_review_skips_reassign_when_no_documenter() -> None: qa_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_awaiting_task(task_id, qa_id) after = MagicMock( - **{ - **t.__dict__, - "status": "awaiting_documentation", - "team": "backend", - "pr_url": "x", - "assigned_to": None, - }, + id=task_id, + status="awaiting_documentation", + team="backend", + pr_url="x", + assigned_to=None, ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent(qa_id) task_svc.qa_pass.return_value = after task_svc.documenter_for_team.return_value = None + task_svc.session = MagicMock() + task_svc.session.begin_nested = _begin_nested_mock() journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = True deps = _make_deps(task=task_svc, journal=journal_svc) @@ -240,12 +293,36 @@ async def test_i_documented_reassigns_task_to_cell_pm() -> None: doc_id = uuid4() pm_id = uuid4() task_id = uuid4() - t = MagicMock(id=task_id, status="claimed", assigned_to=doc_id, team="backend") - after = MagicMock(**{**t.__dict__, "status": "awaiting_pm_review"}) + t = MagicMock( + id=task_id, + status="awaiting_documentation", + task_type="code", + assigned_to=doc_id, + team="backend", + quick_context=None, + documents=[], + ) + after = MagicMock( + id=task_id, + status="awaiting_pm_review", + assigned_to=doc_id, + team="backend", + ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=doc_id, role="documenter", team="backend", slug=None + ) task_svc.docs_complete.return_value = after task_svc.cell_pm_for_team.return_value = MagicMock(id=pm_id) + task_svc.session = MagicMock() + task_svc.session.flush = AsyncMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -286,7 +363,9 @@ async def test_main_pm_complete_clears_assignment_for_ceo() -> None: deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.main_pm_complete(main_pm_id, root_task_id, notes="ready") + env = await c.main_pm_complete( + main_pm_id, root_task_id, notes="root scope reviewed and ready" + ) assert env.error is None task_svc.reassign.assert_awaited_once_with(root_task_id, None) @@ -363,7 +442,9 @@ async def test_cell_pm_complete_reassigns_parent_when_all_subtasks_done() -> Non deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc) c = Choreographer(deps) - env = await c.cell_pm_complete(pm_id, leaf_id, notes="reviewed and merged") + env = await c.cell_pm_complete( + pm_id, leaf_id, notes="cell scope reviewed and merged into parent" + ) assert env.error is None # Parent reassignment should have been issued, and only for the parent. task_svc.reassign.assert_awaited_once_with(parent_id, new_pm_id) @@ -445,18 +526,19 @@ async def test_fail_review_does_not_double_reassign() -> None: qa_id = uuid4() dev_id = uuid4() task_id = uuid4() - t = MagicMock( - id=task_id, - status="claimed", - assigned_to=qa_id, - qa_evidence_inspected=True, - ) + t = _qa_awaiting_task(task_id, qa_id) after = MagicMock( - **{**t.__dict__, "status": "needs_revision", "assigned_to": dev_id}, + id=task_id, + status="needs_revision", + assigned_to=dev_id, + team="backend", ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = _qa_agent(qa_id) task_svc.qa_fail.return_value = after + task_svc.session = MagicMock() + task_svc.session.begin_nested = _begin_nested_mock() journal_svc = AsyncMock() journal_svc.has_learning_for_task.return_value = True a2a_svc = AsyncMock() diff --git a/tests/unit/gateway/test_choreographer_submit_qa_gates.py b/tests/unit/gateway/test_choreographer_submit_qa_gates.py index e840e7a3..846949b3 100644 --- a/tests/unit/gateway/test_choreographer_submit_qa_gates.py +++ b/tests/unit/gateway/test_choreographer_submit_qa_gates.py @@ -39,6 +39,17 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "evidence_repo": AsyncMock(), } base.update(overrides) + # VerbRunner uses task.session.begin_nested() as a savepoint context + # manager. Keep `session` itself an AsyncMock so other awaited methods + # (e.g. flush) still work, and override begin_nested with a sync + # MagicMock that returns the async-context-manager protocol. + task = base["task"] + task.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) repo = base["evidence_repo"] for method in ( "list_unread_a2a", @@ -87,6 +98,11 @@ async def test_i_am_done_auto_runs_submit_verification_when_in_progress() -> Non """Strict i_am_done auto-runs submit_verification (in_progress→verifying) so the dev doesn't need a separate verb. The previous NOT_SELF_VERIFIED gate required submit_for_verification which wasn't on any manifest. + + The pre-flight tracing gate filters SELF_VERIFIED (it is set by the + auto-run submit_verification action and re-asserted by the spec's + own preconditions), so an unverified in_progress task can still + enter i_am_done. """ agent_id = uuid4() task_id = uuid4() @@ -99,6 +115,9 @@ async def test_i_am_done_auto_runs_submit_verification_when_in_progress() -> Non after_submit = MagicMock(**{**after_verify.__dict__, "status": "awaiting_qa"}) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) task_svc.submit_verification.return_value = after_verify task_svc.submit_qa.return_value = after_submit task_svc.qa_agent_for_team.return_value = MagicMock( @@ -106,6 +125,10 @@ async def test_i_am_done_auto_runs_submit_verification_when_in_progress() -> Non ) journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False work_svc = AsyncMock() work_svc.files_changed.return_value = ["foo.py"] deps = _make_deps(task=task_svc, journal=journal_svc, work_session=work_svc) @@ -125,21 +148,31 @@ async def test_i_am_done_auto_runs_submit_verification_when_in_progress() -> Non @pytest.mark.asyncio async def test_i_am_done_blocks_when_no_commits() -> None: + """Spec's PRECONDITION_COMMITS rejects with the canonical + `commits>=1` missing token before any state mutation.""" agent_id = uuid4() task_id = uuid4() t = _ready_task(task_id, agent_id) t.commits = [] task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) env = await c.i_am_done(agent_id, task_id, "done") body = env.as_dict() assert body["error"] == "tracing_gap" - assert "NO_COMMITS" in body["missing"] or "commits" in body["missing"] + # Spec emits "commits>=1" via PRECONDITION_COMMITS. + assert "commits>=1" in body["missing"] or "NO_COMMITS" in body["missing"] task_svc.submit_qa.assert_not_awaited() @@ -150,21 +183,40 @@ async def test_i_am_done_blocks_when_no_commits() -> None: @pytest.mark.asyncio async def test_i_am_done_blocks_when_no_pr() -> None: + """Defense-in-depth field gate fires NO_PR after the spec gate accepts. + + The spec doesn't yet model PR-existence; the field-gate helper still + enforces it post-spec. + """ agent_id = uuid4() task_id = uuid4() t = _ready_task(task_id, agent_id) t.pr_number = None task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) env = await c.i_am_done(agent_id, task_id, "done") body = env.as_dict() assert body["error"] == "tracing_gap" - assert "NO_PR" in body["missing"] or "pr_number" in body["missing"] + # foundation.policy.tracing emits "pr_open" via PR_OPEN; the legacy + # _check_submit_qa_field_gates path emitted "NO_PR" but tracing now + # short-circuits before that field gate runs. + assert ( + "pr_open" in body["missing"] + or "NO_PR" in body["missing"] + or "pr_number" in body["missing"] + ) task_svc.submit_qa.assert_not_awaited() @@ -181,8 +233,15 @@ async def test_i_am_done_blocks_when_no_progress() -> None: t.progress_updates = [] task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False deps = _make_deps(task=task_svc, journal=journal_svc) c = Choreographer(deps) @@ -214,12 +273,19 @@ async def test_i_am_done_proceeds_when_all_gates_pass() -> None: ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) task_svc.submit_qa.return_value = after_submit task_svc.qa_agent_for_team.return_value = MagicMock( id=uuid4(), skills=[{"id": "code_review"}] ) journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False work_svc = AsyncMock() work_svc.files_changed.return_value = ["foo.py"] deps = _make_deps(task=task_svc, journal=journal_svc, work_session=work_svc) @@ -230,7 +296,8 @@ async def test_i_am_done_proceeds_when_all_gates_pass() -> None: assert body["error"] is None assert body["status"] == "awaiting_qa" task_svc.submit_qa.assert_awaited_once() - # Already-verifying status: no auto-call to submit_verification. + # Already-verifying status: recovery path runs only submit_qa, never + # the composed submit_verification action. task_svc.submit_verification.assert_not_awaited() @@ -243,16 +310,27 @@ async def test_i_am_done_proceeds_when_all_gates_pass() -> None: @pytest.mark.asyncio async def test_i_am_done_blocks_unauthorized() -> None: - """Existing not_authorized check still applies.""" + """Spec's PRECONDITION_OWNERSHIP rejects with tracing_gap when the + caller does not own the task. + + Pre-spec migration the verb returned a separate not_authorized + envelope from an inline ownership check; the spec now drives this + decision via PRECONDITION_OWNERSHIP, which surfaces as tracing_gap + with the `owns_task` missing token. + """ agent_id = uuid4() other_id = uuid4() task_id = uuid4() t = _ready_task(task_id, other_id) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.i_am_done(agent_id, task_id, "done") body = env.as_dict() - assert body["error"] == "not_authorized" + assert body["error"] == "tracing_gap" + assert "owns_task" in body["missing"] diff --git a/tests/unit/gateway/test_claim_arg_order.py b/tests/unit/gateway/test_claim_arg_order.py index 8dfda23a..7aef9964 100644 --- a/tests/unit/gateway/test_claim_arg_order.py +++ b/tests/unit/gateway/test_claim_arg_order.py @@ -36,6 +36,18 @@ def _make_deps(**overrides: Any) -> ChoreographerDeps: "evidence_repo": AsyncMock(), } base.update(overrides) + # VerbRunner uses task.session.begin_nested() as a savepoint context + # manager. AsyncMock auto-attributes any access (so hasattr always + # returns True); we always overwrite session to a MagicMock with the + # correct async-context-manager protocol. + task = base["task"] + task.session = MagicMock() + task.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) repo = base["evidence_repo"] for method in ( "list_unread_a2a", @@ -88,7 +100,9 @@ async def test_i_will_work_on_pending_calls_claim_with_task_id_first() -> None: ) task_svc = AsyncMock() task_svc.get.return_value = pending - task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] @@ -108,7 +122,8 @@ async def test_i_will_work_on_pending_calls_claim_with_task_id_first() -> None: @pytest.mark.asyncio async def test_i_will_work_on_needs_revision_calls_start_with_task_id_first() -> None: - """Dev needs_revision resume: start args must be (task_id, agent_id).""" + """Dev needs_revision: spec composes (claim, set_plan, start), so all three + run; positional args on every transition must be (task_id, agent_id).""" agent_id = uuid4() task_id = uuid4() nr = MagicMock( @@ -117,12 +132,34 @@ async def test_i_will_work_on_needs_revision_calls_start_with_task_id_first() -> assigned_to=agent_id, plan={"x": 1}, task_type="code", + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + quick_context=None, + parent_task_id=None, + sequence=0, + team="backend", + ) + claimed = MagicMock( + id=task_id, + status="claimed", + assigned_to=agent_id, + plan={"x": 1}, + task_type="code", ) started = MagicMock( id=task_id, status="in_progress", assigned_to=agent_id, plan={"x": 1} ) task_svc = AsyncMock() task_svc.get.return_value = nr + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) + task_svc.list_in_progress_for_agent.return_value = [] + task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] + task_svc.claim.return_value = claimed + task_svc.set_plan.return_value = claimed task_svc.start.return_value = started deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -130,7 +167,7 @@ async def test_i_will_work_on_needs_revision_calls_start_with_task_id_first() -> env = await c.i_will_work_on(agent_id, task_id) task_svc.start.assert_awaited_once_with(task_id, agent_id) - task_svc.claim.assert_not_awaited() + task_svc.claim.assert_awaited_once_with(task_id, agent_id) assert env.error is None @@ -138,7 +175,12 @@ async def test_i_will_work_on_needs_revision_calls_start_with_task_id_first() -> async def test_i_will_work_on_claimed_resumption_calls_start_with_task_id_first() -> ( None ): - """Dev claimed resumption: start args must be (task_id, agent_id).""" + """Dev claimed resumption: spec's ``claim`` action does not list CLAIMED + as a source-status, so the spec gate would reject. The verb body keeps + a bespoke `claimed` re-entry (``_resume_from_claimed``) for the + recovery scenario where an agent already owns the task and the + orchestrator died mid-claim. start args still use (task_id, agent_id). + """ agent_id = uuid4() task_id = uuid4() claimed = MagicMock( @@ -151,13 +193,18 @@ async def test_i_will_work_on_claimed_resumption_calls_start_with_task_id_first( task_type="code", team="backend", branch_name="feature/backend/abc", + commits=[], + pr_number=None, + quick_context=None, ) started = MagicMock( id=task_id, status="in_progress", plan={"x": 1}, assigned_to=agent_id ) task_svc = AsyncMock() task_svc.get.return_value = claimed - task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") + task_svc.agent_for.return_value = MagicMock( + id=agent_id, role="developer", team="backend", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] @@ -201,9 +248,12 @@ async def test_i_will_plan_calls_claim_and_start_with_task_id_first() -> None: ) task_svc = AsyncMock() task_svc.get.return_value = pending - task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend") + task_svc.agent_for.return_value = MagicMock( + id=pm_id, role="cell_pm", team="backend", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] + task_svc.get_subtasks.return_value = [] task_svc.claim.return_value = claimed task_svc.set_plan.return_value = claimed task_svc.start.return_value = started diff --git a/tests/unit/gateway/test_claim_guards_direct.py b/tests/unit/gateway/test_claim_guards_direct.py index 1ce8453c..d59fbde2 100644 --- a/tests/unit/gateway/test_claim_guards_direct.py +++ b/tests/unit/gateway/test_claim_guards_direct.py @@ -5,39 +5,17 @@ from __future__ import annotations from types import SimpleNamespace from uuid import uuid4 -from roboco.services.gateway.claim_guards import ( - role_typed_claim_guard, - sibling_sequence_guard, -) - - -def test_role_typed_claim_guard_pm_role_skipped() -> None: - """Line 125: pm_role returns None (no role-type guard).""" - assert role_typed_claim_guard("cell_pm", "code") is None - - -def test_role_typed_claim_guard_unknown_role_skipped() -> None: - """Line 129: unknown role returns None (default-allow).""" - assert role_typed_claim_guard("ghost-role", "code") is None - - -def test_role_typed_claim_guard_developer_can_claim_code() -> None: - assert role_typed_claim_guard("developer", "code") is None - - -def test_role_typed_claim_guard_developer_cannot_claim_review() -> None: - env = role_typed_claim_guard("developer", "review") - assert env is not None +from roboco.services.gateway.claim_guards import sibling_sequence_guard def test_sibling_sequence_guard_root_task_passes() -> None: - """Line 152-153: parent_task_id None → no guard.""" + """parent_task_id None → no guard.""" task = SimpleNamespace(id=uuid4(), parent_task_id=None, sequence=5) assert sibling_sequence_guard(task, []) is None def test_sibling_sequence_guard_sequence_zero_passes() -> None: - """Line 156: sequence==0 always allowed.""" + """sequence==0 always allowed.""" task = SimpleNamespace(id=uuid4(), parent_task_id=uuid4(), sequence=0) assert sibling_sequence_guard(task, []) is None diff --git a/tests/unit/gateway/test_delegate_incomplete_input.py b/tests/unit/gateway/test_delegate_incomplete_input.py new file mode 100644 index 00000000..2a13b81b --- /dev/null +++ b/tests/unit/gateway/test_delegate_incomplete_input.py @@ -0,0 +1,224 @@ +"""Gateway delegate must return Envelope.incomplete_input when fields missing. + +Pre-migration: services/gateway/choreographer/_impl.py:1852 used +``acceptance_criteria=inputs.acceptance_criteria or []`` which let ``[]`` +through and was caught later (or, before that fix, silently substituted +by services/task.py:5061). Now the gateway rejects at the boundary +with ``Envelope.incomplete_input``, so the agent receives a structured +field-by-field guide (the spec §5.2.1 interrogation pattern). +""" + +from __future__ import annotations + +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 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 ChoreographerDeps(**base) + + +def _parent_in_progress(pm_id: Any) -> MagicMock: + return MagicMock( + id=uuid4(), + project_id=uuid4(), + status="in_progress", + assigned_to=pm_id, + priority=2, + ) + + +@pytest.mark.asyncio +async def test_delegate_returns_incomplete_input_when_acceptance_criteria_empty() -> ( + None +): + """Empty acceptance_criteria triggers the incomplete_input envelope.""" + pm_id = uuid4() + parent = _parent_in_progress(pm_id) + 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 = [] + deps = _make_deps(task=task_svc) + c = Choreographer(deps) + + env = await c.delegate( + pm_id, + parent.id, + DelegateInputs( + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", + assigned_to="be-dev-1", + team="backend", + task_type="code", + nature="technical", + acceptance_criteria=[], # under-filled — must trigger interrogation + ), + ) + body = env.as_dict() + assert body["error"] == "incomplete_input", body + assert "acceptance_criteria" in body["missing"] + assert "acceptance_criteria" in body["field_hints"] + assert "verifiable" in body["field_hints"]["acceptance_criteria"].lower() + task_svc.create_subtask.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delegate_returns_incomplete_input_when_acceptance_criteria_none() -> ( + None +): + """None acceptance_criteria (missing field) triggers the same envelope.""" + pm_id = uuid4() + parent = _parent_in_progress(pm_id) + 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 = [] + deps = _make_deps(task=task_svc) + c = Choreographer(deps) + + env = await c.delegate( + pm_id, + parent.id, + DelegateInputs( + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", + assigned_to="be-dev-1", + team="backend", + task_type="code", + nature="technical", + acceptance_criteria=None, + ), + ) + body = env.as_dict() + assert body["error"] == "incomplete_input", body + assert "acceptance_criteria" in body["missing"] + task_svc.create_subtask.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delegate_rejects_silent_fallback_phrase() -> None: + """The denylist catches the legacy 'completed and reviewed by assignee' string.""" + pm_id = uuid4() + parent = _parent_in_progress(pm_id) + 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 = [] + deps = _make_deps(task=task_svc) + c = Choreographer(deps) + + env = await c.delegate( + pm_id, + parent.id, + DelegateInputs( + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", + assigned_to="be-dev-1", + team="backend", + task_type="code", + nature="technical", + acceptance_criteria=["completed and reviewed by assignee"], + ), + ) + body = env.as_dict() + assert body["error"] == "incomplete_input", body + assert "acceptance_criteria" in body["missing"] + # The denylist hint should mention the placeholder/legacy fallback. + hint = body["field_hints"]["acceptance_criteria"].lower() + assert "placeholder" in hint or "legacy" in hint or "rejected" in hint + task_svc.create_subtask.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delegate_returns_incomplete_input_when_nature_missing() -> None: + """Missing nature is also caught by task_completeness check.""" + pm_id = uuid4() + parent = _parent_in_progress(pm_id) + 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 = [] + deps = _make_deps(task=task_svc) + c = Choreographer(deps) + + env = await c.delegate( + pm_id, + parent.id, + DelegateInputs( + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", + assigned_to="be-dev-1", + team="backend", + task_type="code", + nature=None, # not declared + acceptance_criteria=["GET /v1/foo returns 200 with body"], + ), + ) + body = env.as_dict() + assert body["error"] == "incomplete_input", body + assert "nature" in body["missing"] + task_svc.create_subtask.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delegate_passes_when_payload_complete() -> None: + """Fully-populated payload still creates the subtask (no regression).""" + pm_id = uuid4() + parent = _parent_in_progress(pm_id) + new_task = MagicMock(id=uuid4()) + 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 = new_task + deps = _make_deps(task=task_svc) + c = Choreographer(deps) + + env = await c.delegate( + pm_id, + parent.id, + DelegateInputs( + title="Implement endpoint", + description="Add /v1/foo endpoint with passing tests please", + assigned_to="be-dev-1", + team="backend", + task_type="code", + nature="technical", + acceptance_criteria=["GET /v1/foo returns 200 with body"], + ), + ) + assert env.error is None, env.as_dict() + task_svc.create_subtask.assert_awaited_once() + # Verify nature threaded through to TaskCreateRequest. + req = task_svc.create_subtask.call_args.args[0] + assert str(req.nature) == "technical" or req.nature.value == "technical" diff --git a/tests/unit/gateway/test_envelope_from_decision.py b/tests/unit/gateway/test_envelope_from_decision.py new file mode 100644 index 00000000..11f41785 --- /dev/null +++ b/tests/unit/gateway/test_envelope_from_decision.py @@ -0,0 +1,58 @@ +"""Envelope.from_decision maps a Decision to the right rejection envelope.""" + +from __future__ import annotations + +import pytest +from roboco.foundation.policy import lifecycle as spec +from roboco.services.gateway.envelope import Envelope + + +def test_from_decision_not_authorized_maps_to_not_authorized_envelope() -> None: + d = spec.Decision.reject( + kind="not_authorized", + message="role 'developer' may not call delegate", + remediate="only PMs delegate", + ) + env = Envelope.from_decision(d, briefing={}) + assert env.error == "not_authorized" + assert env.message == "role 'developer' may not call delegate" + assert env.remediate == "only PMs delegate" + + +def test_from_decision_invalid_state_maps_to_invalid_state_envelope() -> None: + d = spec.Decision.reject( + kind="invalid_state", + message="task in 'pending', 'open_pr' requires ['in_progress']", + remediate="claim the task first via i_will_work_on", + ) + env = Envelope.from_decision(d, briefing={}) + assert env.error == "invalid_state" + + +def test_from_decision_tracing_gap_carries_missing() -> None: + d = spec.Decision.tracing_gap( + missing=["plan", "journal:decision"], + remediate="provide plan and a journal:decision entry", + ) + env = Envelope.from_decision(d, briefing={}) + assert env.error == "tracing_gap" + assert env.missing == ["plan", "journal:decision"] + assert env.remediate == "provide plan and a journal:decision entry" + + +def test_from_decision_self_review_maps_to_not_authorized_with_hint() -> None: + d = spec.Decision.reject( + kind="self_review", + message="self-review blocked", + remediate="another QA must review", + ) + env = Envelope.from_decision(d, briefing={}) + assert env.error == "not_authorized" + assert "self-review" in env.message.lower() + + +def test_from_decision_allowed_raises() -> None: + """Constructing a rejection envelope from an allow Decision is a bug.""" + d = spec.Decision.allow() + with pytest.raises(ValueError, match="cannot build rejection from allow Decision"): + Envelope.from_decision(d, briefing={}) diff --git a/tests/unit/gateway/test_envelope_incomplete_input.py b/tests/unit/gateway/test_envelope_incomplete_input.py new file mode 100644 index 00000000..a28c8164 --- /dev/null +++ b/tests/unit/gateway/test_envelope_incomplete_input.py @@ -0,0 +1,34 @@ +"""Envelope.incomplete_input is the structured-rejection envelope for +under-filled inputs (spec §5.2.1 interrogation pattern). It's distinct +from tracing_gap (which is for end-of-workflow gates). +""" + +from __future__ import annotations + +from roboco.services.gateway.envelope import Envelope + + +def test_incomplete_input_carries_missing_and_field_hints() -> None: + env = Envelope.incomplete_input( + missing=["acceptance_criteria", "nature"], + field_hints={ + "acceptance_criteria": "non-empty list[str]", + "nature": "one of: technical | bugfix | feature | refactor | docs", + }, + remediate="re-issue with these fields filled", + ) + body = env.as_dict() + assert body["error"] == "incomplete_input" + assert body["missing"] == ["acceptance_criteria", "nature"] + assert body["remediate"] == "re-issue with these fields filled" + assert body["field_hints"]["acceptance_criteria"] == "non-empty list[str]" + + +def test_incomplete_input_distinct_from_tracing_gap() -> None: + """incomplete_input and tracing_gap MUST have different `error` values + so prompts can teach agents to handle them differently.""" + env_inc = Envelope.incomplete_input( + missing=["x"], field_hints={"x": "y"}, remediate="z" + ) + env_gap = Envelope.tracing_gap(missing=["x"], remediate="z") + assert env_inc.as_dict()["error"] != env_gap.as_dict()["error"] diff --git a/tests/unit/gateway/test_envelope_introspection.py b/tests/unit/gateway/test_envelope_introspection.py index aa0d4c49..73f906e4 100644 --- a/tests/unit/gateway/test_envelope_introspection.py +++ b/tests/unit/gateway/test_envelope_introspection.py @@ -16,7 +16,10 @@ def test_envelope_ok_carries_introspection_when_task_supplied() -> None: ).with_introspection(task=task, role="developer") body = env.as_dict() assert body["current_state"] == "in_progress" - assert "commit" in body["valid_next_verbs"] + # `valid_next_verbs` lists lifecycle INTENT verbs; `commit` is a + # content tool (do_server), not an intent, and is intentionally + # excluded under the canonical spec. + assert "open_pr" in body["valid_next_verbs"] assert "i_am_done" in body["valid_next_verbs"] diff --git a/tests/unit/gateway/test_heartbeat_wired.py b/tests/unit/gateway/test_heartbeat_wired.py index 993b8707..7af4ea6a 100644 --- a/tests/unit/gateway/test_heartbeat_wired.py +++ b/tests/unit/gateway/test_heartbeat_wired.py @@ -11,6 +11,15 @@ from roboco.services.gateway.choreographer import Choreographer, ChoreographerDe def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps: task = overrides.get("task", AsyncMock()) + # VerbRunner uses task.session.begin_nested() as a savepoint context + # manager; ensure the mock satisfies that protocol. + task.session = MagicMock() + task.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) work_session = overrides.get("work_session", AsyncMock()) git = overrides.get("git", AsyncMock()) a2a = overrides.get("a2a", AsyncMock()) @@ -49,13 +58,20 @@ async def test_i_will_work_on_calls_heartbeat() -> None: parent_task_id=None, sequence=0, task_type="code", + commits=[], + pr_number=None, + branch_name="feature/backend/abc", + quick_context=None, + team="backend", ) in_progress = MagicMock( id=tid, status="in_progress", plan={"text": "go"}, assigned_to=aid ) task_svc = AsyncMock() task_svc.get.return_value = pending - task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) task_svc.list_in_progress_for_agent.return_value = [] task_svc.list_paused_for_agent.return_value = [] task_svc.get_subtasks.return_value = [] @@ -101,10 +117,17 @@ async def test_i_am_done_calls_heartbeat() -> None: ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) task_svc.submit_qa.return_value = submitted task_svc.qa_agent_for_team.return_value = None journal_svc = AsyncMock() journal_svc.has_reflect_for_task.return_value = True + # JOURNAL_DURING_WORK_AT_LEAST_ONE: ≥1 decision/learning/struggle entry. + journal_svc.has_decision_for_task.return_value = True + journal_svc.has_learning_for_task.return_value = False + journal_svc.has_struggle_for_task.return_value = False evidence_repo = AsyncMock() for method in ( "list_unread_a2a", @@ -134,10 +157,19 @@ async def test_i_am_done_calls_heartbeat() -> None: async def test_i_am_blocked_calls_heartbeat() -> None: aid = uuid4() tid = uuid4() - t = MagicMock(id=tid, status="in_progress", assigned_to=aid) + t = MagicMock( + id=tid, + status="in_progress", + assigned_to=aid, + task_type="code", + team="backend", + ) blocked = MagicMock(id=tid, status="blocked", assigned_to=aid) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) task_svc.escalate.return_value = blocked journal_svc = AsyncMock() deps = _make_deps(task=task_svc, journal=journal_svc) diff --git a/tests/unit/gateway/test_open_pr.py b/tests/unit/gateway/test_open_pr.py index dd4e79f7..836d9e19 100644 --- a/tests/unit/gateway/test_open_pr.py +++ b/tests/unit/gateway/test_open_pr.py @@ -43,6 +43,22 @@ def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps: ) +def _wire_savepoint(task_svc: AsyncMock) -> None: + """Stub task_svc.session.begin_nested() as an async context manager. + + The VerbRunner wraps composed atomic actions in `session.begin_nested()`. + open_pr has composes=() so the savepoint body is empty, but the + context manager is still entered/exited. Tests need a no-op stub. + """ + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) + + @pytest.mark.asyncio async def test_open_pr_pushes_and_opens_pr() -> None: aid = uuid4() @@ -56,8 +72,22 @@ async def test_open_pr_pushes_and_opens_pr() -> None: pr_number=None, branch_name="feature/backend/abc12345", ) + # Re-fetched task post-runner has the new pr_number written by + # git_service.create_pr's _record_pr_atomically. + t_after = MagicMock( + id=tid, + status="in_progress", + assigned_to=aid, + plan="x", + commits=[{"sha": "abc"}], + pr_number=42, + pr_url="https://gh/x/42", + branch_name="feature/backend/abc12345", + ) task_svc = AsyncMock() - task_svc.get.return_value = t + task_svc.get.side_effect = [t, t_after] + task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") + _wire_savepoint(task_svc) git_svc = AsyncMock() git_svc.push_branch.return_value = ("feature/backend/abc12345", 1) git_svc.create_pr.return_value = { @@ -75,7 +105,10 @@ async def test_open_pr_pushes_and_opens_pr() -> None: git_svc.create_pr.assert_awaited() assert env.error is None assert env.next is not None - assert "42" in env.next # remediate points to i_am_done with PR ref + # spec's next_hint("open_pr") returns the canonical i_am_done remediation; + # PR number is surfaced via introspection.with_introspection() rather + # than the next-hint string itself. + assert "i_am_done" in env.next @pytest.mark.asyncio @@ -94,6 +127,7 @@ async def test_open_pr_rejects_when_not_assigned() -> None: ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") git_svc = AsyncMock() deps = _make_deps(task=task_svc, git=git_svc) c = Choreographer(deps) @@ -102,7 +136,10 @@ async def test_open_pr_rejects_when_not_assigned() -> None: git_svc.push_branch.assert_not_awaited() git_svc.create_pr.assert_not_awaited() - assert env.error == "not_authorized" + # Spec's PRECONDITION_OWNERSHIP surfaces as tracing_gap (owns_task missing) + # rather than the previous bespoke not_authorized message. + assert env.error == "tracing_gap" + assert env.missing == ["owns_task"] @pytest.mark.asyncio @@ -120,6 +157,7 @@ async def test_open_pr_rejects_when_no_commits() -> None: ) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock(role="developer", team="backend") git_svc = AsyncMock() deps = _make_deps(task=task_svc, git=git_svc) c = Choreographer(deps) @@ -128,7 +166,10 @@ async def test_open_pr_rejects_when_no_commits() -> None: git_svc.push_branch.assert_not_awaited() git_svc.create_pr.assert_not_awaited() - assert env.error == "invalid_state" + # Spec's PRECONDITION_COMMITS surfaces as tracing_gap; remediate + # still mentions committing. + assert env.error == "tracing_gap" + assert env.missing == ["commits>=1"] assert env.remediate is not None assert "commit" in env.remediate.lower() diff --git a/tests/unit/gateway/test_resume.py b/tests/unit/gateway/test_resume.py index da6ffc5e..1d86c779 100644 --- a/tests/unit/gateway/test_resume.py +++ b/tests/unit/gateway/test_resume.py @@ -54,9 +54,21 @@ async def test_resume_transitions_paused_to_in_progress() -> None: t = MagicMock(id=tid, status="paused", assigned_to=aid) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) task_svc.resume_for_agent.return_value = MagicMock( id=tid, status="in_progress", assigned_to=aid ) + # VerbRunner wraps composed actions in session.begin_nested(); wire up + # an async-context-manager mock so the runner doesn't fail on dispatch. + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -91,12 +103,19 @@ async def test_resume_rejects_when_not_claimant() -> None: t = MagicMock(id=tid, status="paused", assigned_to=other) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.resume(aid, tid) + # The spec gate accepts (developer is in resume's allowed_roles and + # status is paused); the reassignment-rejection branch (Task 6 fix in + # commit a5d358d) is what rejects with "current owner". assert env.error == "not_authorized" + assert "current owner" in (env.message or "") task_svc.resume_for_agent.assert_not_awaited() @@ -104,11 +123,15 @@ async def test_resume_rejects_when_not_claimant() -> None: async def test_resume_rejects_invalid_state() -> None: aid = uuid4() tid = uuid4() - # Task is owned but not paused (e.g. status drifted to in_progress between - # get and write). Service-level guard refuses by returning None. + # Task is owned but not paused. resume's IntentSpec composes=("resume",) + # and the resume ActionSpec's source_statuses is {PAUSED}, so the spec + # gate rejects with invalid_state — VerbRunner never runs. t = MagicMock(id=tid, status="in_progress", assigned_to=aid) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) task_svc.resume_for_agent.return_value = None deps = _make_deps(task=task_svc) c = Choreographer(deps) @@ -116,7 +139,9 @@ async def test_resume_rejects_invalid_state() -> None: env = await c.resume(aid, tid) assert env.error == "invalid_state" - task_svc.resume_for_agent.assert_awaited_once_with(tid, aid) + # Spec gate rejects before the runner dispatches, so resume_for_agent + # is NOT called. + task_svc.resume_for_agent.assert_not_awaited() @pytest.mark.asyncio @@ -147,9 +172,21 @@ async def test_resume_success_writes_heartbeat() -> None: t = MagicMock(id=tid, status="paused", assigned_to=aid) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) task_svc.resume_for_agent.return_value = MagicMock( id=tid, status="in_progress", assigned_to=aid ) + # VerbRunner wraps composed actions in session.begin_nested(); wire up + # an async-context-manager mock so the runner doesn't fail on dispatch. + task_svc.session = MagicMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(return_value=None), + __aexit__=AsyncMock(return_value=False), + ) + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_role_config.py b/tests/unit/gateway/test_role_config.py index ca1264cb..af6d05e0 100644 --- a/tests/unit/gateway/test_role_config.py +++ b/tests/unit/gateway/test_role_config.py @@ -3,7 +3,10 @@ from __future__ import annotations import pytest +from roboco.foundation.policy import lifecycle as spec from roboco.services.gateway.role_config import ( + _DEV_FLOW, + _QA_FLOW, ROLE_CONFIGS, get_role_config, ) @@ -24,8 +27,10 @@ class TestRoleConfigCatalog: def test_qa_config(self) -> None: cfg = get_role_config("qa") assert "claim_review" in cfg.flow_tools - assert "pass" in cfg.flow_tools - assert "fail" in cfg.flow_tools + # Spec canon: pass_review / fail_review (the legacy `pass`/`fail` + # MCP-facing aliases live in flow_server's _TOOLS map, not here). + assert "pass_review" in cfg.flow_tools + assert "fail_review" in cfg.flow_tools # QA does NOT have i_am_done / commit assert "i_am_done" not in cfg.flow_tools assert "commit" not in cfg.do_tools @@ -61,3 +66,13 @@ class TestRoleConfigCatalog: for cfg in ROLE_CONFIGS.values(): assert "ToolSearch" not in cfg.flow_tools assert "ToolSearch" not in cfg.do_tools + + +def test_dev_flow_matches_spec_intents_for_role() -> None: + """role_config._DEV_FLOW must equal spec.intents_for_role(Role.DEVELOPER).""" + assert tuple(_DEV_FLOW) == spec.intents_for_role(spec.Role.DEVELOPER) + + +def test_qa_flow_matches_spec_intents_for_role() -> None: + """role_config._QA_FLOW must equal spec.intents_for_role(Role.QA).""" + assert tuple(_QA_FLOW) == spec.intents_for_role(spec.Role.QA) diff --git a/tests/unit/gateway/test_tracing_gate.py b/tests/unit/gateway/test_tracing_gate.py deleted file mode 100644 index 1cb1f46e..00000000 --- a/tests/unit/gateway/test_tracing_gate.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Tests for tracing-completeness preconditions.""" - -from __future__ import annotations - -from unittest.mock import MagicMock -from uuid import uuid4 - -from roboco.services.gateway.tracing_gate import ( - GateContext, - Requirement, - check_requirements, -) - - -def _task() -> MagicMock: - """Bare Task fixture; tests set the attributes they need.""" - t = MagicMock() - t.id = uuid4() - t.plan = None - t.progress_updates = [] - t.acceptance_criteria = [] - t.acceptance_criteria_status = [] - t.qa_notes = None - t.qa_evidence_inspected = False - t.self_verified = False - return t - - -class TestCheckRequirements: - def test_plan_present(self) -> None: - t = _task() - t.plan = {"steps": ["a", "b"]} - result = check_requirements(t, [Requirement.PLAN]) - assert result.passed is True - - def test_plan_missing(self) -> None: - t = _task() - result = check_requirements(t, [Requirement.PLAN]) - assert result.passed is False - assert "plan" in result.missing[0].lower() - - def test_progress_present(self) -> None: - t = _task() - t.progress_updates = [{"message": "did stuff", "ts": "..."}] - result = check_requirements(t, [Requirement.PROGRESS_AT_LEAST_ONE]) - assert result.passed is True - - def test_progress_missing(self) -> None: - t = _task() - result = check_requirements(t, [Requirement.PROGRESS_AT_LEAST_ONE]) - assert result.passed is False - - def test_journal_reflect_required(self) -> None: - t = _task() - absent = check_requirements( - t, [Requirement.JOURNAL_REFLECT], GateContext(journal_reflect_present=False) - ) - assert absent.passed is False - present = check_requirements( - t, [Requirement.JOURNAL_REFLECT], GateContext(journal_reflect_present=True) - ) - assert present.passed is True - - def test_acceptance_criteria_all_addressed(self) -> None: - t = _task() - t.acceptance_criteria = ["AC1", "AC2"] - t.acceptance_criteria_status = [ - {"criterion": "AC1", "referencing_artifact_id": "commit-abc"}, - {"criterion": "AC2", "referencing_artifact_id": "note-xyz"}, - ] - result = check_requirements(t, [Requirement.ACCEPTANCE_CRITERIA_ADDRESSED]) - assert result.passed is True - - def test_acceptance_criteria_partial_fails(self) -> None: - t = _task() - t.acceptance_criteria = ["AC1", "AC2", "AC3"] - t.acceptance_criteria_status = [ - {"criterion": "AC1", "referencing_artifact_id": "commit-abc"}, - ] - result = check_requirements(t, [Requirement.ACCEPTANCE_CRITERIA_ADDRESSED]) - assert result.passed is False - assert any("AC2" in m or "AC3" in m for m in result.missing) - - def test_qa_notes_min_chars(self) -> None: - t = _task() - t.qa_notes = "short" - result = check_requirements( - t, [Requirement.QA_NOTES_MIN_CHARS], GateContext(qa_notes_min_chars=80) - ) - assert result.passed is False - - def test_qa_evidence_inspected(self) -> None: - t = _task() - result = check_requirements(t, [Requirement.QA_EVIDENCE_INSPECTED]) - assert result.passed is False - - def test_combined_pass(self) -> None: - t = _task() - t.plan = {"steps": ["x"]} - t.progress_updates = [{"message": "did"}] - t.acceptance_criteria = ["AC1"] - t.acceptance_criteria_status = [ - {"criterion": "AC1", "referencing_artifact_id": "c-1"} - ] - result = check_requirements( - t, - [ - Requirement.PLAN, - Requirement.PROGRESS_AT_LEAST_ONE, - Requirement.ACCEPTANCE_CRITERIA_ADDRESSED, - Requirement.JOURNAL_REFLECT, - ], - GateContext(journal_reflect_present=True), - ) - assert result.passed is True - - def test_journal_decision_present(self) -> None: - """Line 64: journal:decision present passes.""" - t = _task() - result = check_requirements( - t, - [Requirement.JOURNAL_DECISION], - GateContext(journal_decision_present=True), - ) - assert result.passed is True - - def test_journal_decision_missing(self) -> None: - t = _task() - result = check_requirements( - t, - [Requirement.JOURNAL_DECISION], - GateContext(journal_decision_present=False), - ) - assert result.passed is False - assert "journal:decision" in result.missing - - def test_journal_learning_present(self) -> None: - """Line 68: journal:learning present passes.""" - t = _task() - result = check_requirements( - t, - [Requirement.JOURNAL_LEARNING], - GateContext(journal_learning_present=True), - ) - assert result.passed is True - - def test_journal_learning_missing(self) -> None: - t = _task() - result = check_requirements( - t, - [Requirement.JOURNAL_LEARNING], - GateContext(journal_learning_present=False), - ) - assert result.passed is False - assert "journal:learning" in result.missing - - def test_self_verified_present(self) -> None: - """Line 85: self_verified=True passes.""" - t = _task() - t.self_verified = True - result = check_requirements(t, [Requirement.SELF_VERIFIED]) - assert result.passed is True - - def test_self_verified_missing(self) -> None: - t = _task() - t.self_verified = False - result = check_requirements(t, [Requirement.SELF_VERIFIED]) - assert result.passed is False - assert "self_verified" in result.missing diff --git a/tests/unit/gateway/test_unclaim.py b/tests/unit/gateway/test_unclaim.py index 6f41a31d..9eb19b5f 100644 --- a/tests/unit/gateway/test_unclaim.py +++ b/tests/unit/gateway/test_unclaim.py @@ -52,6 +52,9 @@ async def test_unclaim_returns_task_to_pending() -> None: t = MagicMock(id=tid, status="claimed", assigned_to=aid) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) task_svc.unclaim_for_agent.return_value = MagicMock( id=tid, status="pending", assigned_to=None ) @@ -89,12 +92,19 @@ async def test_unclaim_rejects_when_not_claimant() -> None: t = MagicMock(id=tid, status="claimed", assigned_to=other) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) deps = _make_deps(task=task_svc) c = Choreographer(deps) env = await c.unclaim(aid, tid) + # The spec gate accepts (developer is in unclaim's allowed_roles and + # composes=() means no state check); the reassignment-rejection branch + # (Task 6 fix in commit a5d358d) is what rejects with "current owner". assert env.error == "not_authorized" + assert "current owner" in (env.message or "") task_svc.unclaim_for_agent.assert_not_awaited() @@ -102,11 +112,17 @@ async def test_unclaim_rejects_when_not_claimant() -> None: async def test_unclaim_rejects_invalid_state() -> None: aid = uuid4() tid = uuid4() - # Status is claimed (assigned_to matches), but the service-level guard - # refuses (e.g. status drifted to verifying between get and write). + # unclaim's IntentSpec has composes=(), so the spec gate does NOT + # enforce a source-status — the verb body owns dispatch, and the + # service-level guard is what refuses (e.g. status drifted between + # get and write). Returning None from unclaim_for_agent surfaces as + # invalid_state from the verb body. t = MagicMock(id=tid, status="verifying", assigned_to=aid) task_svc = AsyncMock() task_svc.get.return_value = t + task_svc.agent_for.return_value = MagicMock( + id=aid, role="developer", team="backend", slug=None + ) task_svc.unclaim_for_agent.return_value = None deps = _make_deps(task=task_svc) c = Choreographer(deps) diff --git a/tests/unit/gateway/test_verb_gates.py b/tests/unit/gateway/test_verb_gates.py deleted file mode 100644 index 5675e827..00000000 --- a/tests/unit/gateway/test_verb_gates.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Tests for the central verb-gate table. - -verb_gates.valid_next_verbs(role, task) is the single source of truth -for which verbs a given (role, task_status, task_type) can call. Used -to populate Envelope.valid_next_verbs so agents know what to do next -without trial-and-error against the gateway. -""" - -from __future__ import annotations - -from types import SimpleNamespace - -from roboco.services.gateway.verb_gates import is_verb_allowed, valid_next_verbs - - -def _task(status: str, task_type: str = "code", **kw: object) -> SimpleNamespace: - """Build a minimal task-shaped object for the gates to inspect.""" - return SimpleNamespace(status=status, task_type=task_type, **kw) - - -# --------------------------------------------------------------------- -# Developer -# --------------------------------------------------------------------- - - -def test_developer_pending_task_can_claim() -> None: - verbs = valid_next_verbs("developer", _task("pending")) - assert "i_will_work_on" in verbs - assert "complete" not in verbs - assert "delegate" not in verbs - - -def test_developer_in_progress_task_can_commit_and_finish() -> None: - verbs = valid_next_verbs("developer", _task("in_progress")) - assert "commit" in verbs - assert "open_pr" in verbs - assert "i_am_done" in verbs - assert "i_am_blocked" in verbs - - -def test_developer_needs_revision_can_re_claim() -> None: - verbs = valid_next_verbs("developer", _task("needs_revision")) - assert "i_will_work_on" in verbs - - -# --------------------------------------------------------------------- -# Cell PM -# --------------------------------------------------------------------- - - -def test_cell_pm_pending_task_can_plan_any_type() -> None: - """Regression for the 2026-05-08 deadlock: PMs can plan ANY task_type - (planning IS coordination, not execution).""" - for task_type in ("code", "documentation", "research", "planning"): - verbs = valid_next_verbs("cell_pm", _task("pending", task_type=task_type)) - assert "i_will_plan" in verbs, f"cell_pm should plan {task_type}" - - -def test_cell_pm_cannot_execute_code() -> None: - """The `i_will_work_on` verb is NEVER offered to cell_pm regardless of - task_type — PMs delegate, devs execute.""" - verbs = valid_next_verbs("cell_pm", _task("pending", task_type="code")) - assert "i_will_work_on" not in verbs - - -def test_cell_pm_in_progress_can_delegate_and_complete() -> None: - verbs = valid_next_verbs("cell_pm", _task("in_progress")) - assert "delegate" in verbs - assert "complete" in verbs - - -# --------------------------------------------------------------------- -# Main PM -# --------------------------------------------------------------------- - - -def test_main_pm_awaiting_pm_review_can_complete_or_escalate() -> None: - verbs = valid_next_verbs("main_pm", _task("awaiting_pm_review")) - assert "complete" in verbs - assert "escalate_to_ceo" in verbs - - -def test_main_pm_claimed_task_cannot_complete() -> None: - """The 2026-05-08 trace showed main-pm spamming `complete` against a - claimed task (which expects awaiting_pm_review). Don't offer it. - """ - # The choreographer's `complete` verb requires awaiting_pm_review; - # offering `complete` on `claimed` would be misleading. (Note: the - # current table DOES include `complete` on `claimed` for PMs because - # main_pm self-claim+complete on a paperwork task is legal — guarding - # the agent prompt is what matters most. The test below pins what - # actually matters: claimed-state main-pm should NOT see verbs that - # require a downstream lifecycle. See plan note in Task 2.) - verbs = valid_next_verbs("main_pm", _task("claimed")) - # `complete` on claimed-state PM tasks is intentionally allowed for - # paperwork-style flows. The trace's spam was actually against a - # task in `pending`/`in_progress`, not `claimed`. This regression - # test pins the contract: a `pending`-state PM task should NOT - # offer `complete` to the agent. - pending_verbs = valid_next_verbs("main_pm", _task("pending")) - assert "complete" not in pending_verbs - # Sanity: claimed PM tasks DO surface `delegate`. - assert "delegate" in verbs - - -# --------------------------------------------------------------------- -# QA -# --------------------------------------------------------------------- - - -def test_qa_awaiting_qa_can_pass_or_fail_after_claim() -> None: - """QA workflow: awaiting_qa → claim_review → (pass | fail). - - On `awaiting_qa` the only lifecycle verb is `claim_review` (QA - claims the review). After claim, status is `claimed` and - pass/fail become available. - """ - awaiting = valid_next_verbs("qa", _task("awaiting_qa")) - assert "claim_review" in awaiting - claimed = valid_next_verbs("qa", _task("claimed")) - assert "pass" in claimed - assert "fail" in claimed - - -def test_qa_does_not_use_i_will_work_on() -> None: - """QA uses `claim_review`, not `i_will_work_on`.""" - awaiting = valid_next_verbs("qa", _task("awaiting_qa")) - assert "i_will_work_on" not in awaiting - claimed = valid_next_verbs("qa", _task("claimed")) - assert "i_will_work_on" not in claimed - - -# --------------------------------------------------------------------- -# Terminal states -# --------------------------------------------------------------------- - - -def test_completed_task_offers_no_lifecycle_verbs() -> None: - verbs = valid_next_verbs("developer", _task("completed")) - # Idle / observation verbs may still be offered; lifecycle verbs aren't. - assert "i_will_work_on" not in verbs - assert "commit" not in verbs - assert "open_pr" not in verbs - - -def test_unknown_role_returns_empty_list() -> None: - assert valid_next_verbs("unknown_role", _task("pending")) == [] - - -def test_idle_verbs_always_available_for_developer() -> None: - """`i_am_idle` and `give_me_work` are always offered regardless of - whether the agent has an active task.""" - for status in ("pending", "claimed", "in_progress", "completed"): - verbs = valid_next_verbs("developer", _task(status)) - assert "i_am_idle" in verbs - assert "give_me_work" in verbs - - -# --------------------------------------------------------------------- -# is_verb_allowed -# --------------------------------------------------------------------- - - -def test_is_verb_allowed_true_for_offered_verb() -> None: - assert is_verb_allowed("developer", "i_will_work_on", _task("pending")) is True - - -def test_is_verb_allowed_false_for_blocked_verb() -> None: - assert is_verb_allowed("cell_pm", "i_will_work_on", _task("pending")) is False - - -def test_is_verb_allowed_false_for_unknown_role() -> None: - assert is_verb_allowed("nope", "i_am_idle", _task("pending")) is False - - -# --------------------------------------------------------------------- -# Task 4: verb_gates is the single source of truth for content tools too -# --------------------------------------------------------------------- - - -def test_commit_allowed_for_developer_and_documenter_only() -> None: - """Was a hardcoded set in content_actions; now lives in verb_gates.""" - task = _task("in_progress", task_type="code") - assert is_verb_allowed("developer", "commit", task) is True - assert is_verb_allowed("documenter", "commit", task) is True - assert is_verb_allowed("qa", "commit", task) is False - assert is_verb_allowed("cell_pm", "commit", task) is False - assert is_verb_allowed("main_pm", "commit", task) is False - - -def test_commit_not_offered_when_task_is_not_in_progress() -> None: - """commit is a per-state verb — not offered when state forbids it.""" - task = _task("completed", task_type="code") - assert is_verb_allowed("developer", "commit", task) is False - - -def test_notify_allowed_for_pm_and_board_only() -> None: - """Was a hardcoded set in content_actions; now lives in verb_gates.""" - task = _task("in_progress", task_type="code") - assert is_verb_allowed("cell_pm", "notify", task) is True - assert is_verb_allowed("main_pm", "notify", task) is True - assert is_verb_allowed("product_owner", "notify", task) is True - assert is_verb_allowed("head_marketing", "notify", task) is True - assert is_verb_allowed("developer", "notify", task) is False - assert is_verb_allowed("qa", "notify", task) is False - assert is_verb_allowed("documenter", "notify", task) is False - assert is_verb_allowed("auditor", "notify", task) is False diff --git a/tests/unit/gateway/test_verb_runner.py b/tests/unit/gateway/test_verb_runner.py new file mode 100644 index 00000000..9199b2c5 --- /dev/null +++ b/tests/unit/gateway/test_verb_runner.py @@ -0,0 +1,104 @@ +"""Verb runner — wraps spec.composed_actions_for in a savepoint. + +Atomicity invariant: preconditions checked BEFORE side effects. +A mid-sequence atomic-action failure rolls the DB back to the +pre-call state; git side effects are runs AFTER the savepoint +commits. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from roboco.foundation.policy import lifecycle as spec +from roboco.services.gateway.choreographer._verb_runner import ( + VerbRunner, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + +@pytest.mark.asyncio +async def test_runner_runs_composed_actions_in_order() -> None: + """For i_will_work_on the runner runs claim, then set_plan, then start.""" + task_svc = AsyncMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()) + ) + runner = VerbRunner(task_service=task_svc, git_service=AsyncMock()) + + calls: list[str] = [] + + def _record(name: str, status: str) -> Callable[..., MagicMock]: + def _inner(*_args: object, **_kwargs: object) -> MagicMock: + calls.append(name) + return MagicMock(status=status) + + return _inner + + task_svc.claim = AsyncMock(side_effect=_record("claim", "claimed")) + task_svc.set_plan = AsyncMock(side_effect=_record("set_plan", "claimed")) + task_svc.start = AsyncMock(side_effect=_record("start", "in_progress")) + + task = MagicMock(id=uuid4(), status="pending", plan=None, commits=[]) + agent = MagicMock(id=uuid4(), role="developer") + ctx = spec.Context(plan="my plan") + + final_task = await runner.run_intent("i_will_work_on", task, agent, ctx) + assert calls == ["claim", "set_plan", "start"] + assert final_task.status == "in_progress" + + +@pytest.mark.asyncio +async def test_runner_runs_side_effects_after_db_commit() -> None: + """For open_pr: composes is empty; side_effects (push_branch, create_pr) run.""" + task_svc = AsyncMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()) + ) + git_svc = AsyncMock() + git_svc.push_branch = AsyncMock() + git_svc.create_pr = AsyncMock(return_value={"pr_number": 42}) + runner = VerbRunner(task_service=task_svc, git_service=git_svc) + + task = MagicMock( + id=uuid4(), + status="in_progress", + commits=["abc"], + pr_number=None, + branch_name="feature/backend/ABC12345", + ) + agent = MagicMock(id=uuid4(), role="developer") + ctx = spec.Context() + + await runner.run_intent("open_pr", task, agent, ctx) + git_svc.push_branch.assert_awaited_once() + git_svc.create_pr.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_runner_does_not_run_side_effects_if_compose_fails() -> None: + """If a composed atomic action raises, side effects must NOT run.""" + task_svc = AsyncMock() + task_svc.session.begin_nested = MagicMock( + return_value=MagicMock( + __aenter__=AsyncMock(), + __aexit__=AsyncMock(side_effect=RuntimeError("rolled back")), + ) + ) + task_svc.claim = AsyncMock(side_effect=RuntimeError("workspace down")) + git_svc = AsyncMock() + runner = VerbRunner(task_service=task_svc, git_service=git_svc) + + task = MagicMock(id=uuid4(), status="pending", plan=None, commits=[]) + agent = MagicMock(id=uuid4(), role="developer") + ctx = spec.Context(plan="x") + + with pytest.raises(RuntimeError): + await runner.run_intent("i_will_work_on", task, agent, ctx) + git_svc.push_branch.assert_not_called() + git_svc.create_pr.assert_not_called() diff --git a/tests/unit/mcp_servers/test_flow_server_circuit_breaker.py b/tests/unit/mcp_servers/test_flow_server_circuit_breaker.py new file mode 100644 index 00000000..c30f64d3 --- /dev/null +++ b/tests/unit/mcp_servers/test_flow_server_circuit_breaker.py @@ -0,0 +1,431 @@ +"""flow_server wires gateway rejections into the SDK per-verb circuit breaker. + +Phase 3 Task 14 added the SDK-side tracker (POST /verb/attempted, +GET /verb/circuit_status, Envelope.circuit_open). This module verifies +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/...') +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. +""" + +from __future__ import annotations + +import importlib +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import pytest + +if TYPE_CHECKING: + import types + from pathlib import Path + + +_FULL_MANIFEST = { + "agent_id": "00000000-0000-0000-0000-000000000099", + "role": "developer", + "team": "backend", + "workspace_path": "/tmp/test", + "flow_tools": [ + "give_me_work", + "i_will_work_on", + "open_pr", + "i_am_done", + "i_am_blocked", + "unclaim", + "resume", + "i_am_idle", + "claim_review", + "pass", + "fail", + "claim_doc_task", + "i_documented", + "triage", + "triage_all", + "unblock", + "complete", + "escalate_up", + "i_will_plan", + "delegate", + "submit_up", + "escalate_to_ceo", + ], + "do_tools": ["commit", "note", "say", "dm", "evidence"], + "read_tools": ["Read", "Glob", "Grep"], + "write_tools": ["Edit", "Write"], + "bash_allowed": True, + "subagent_allowed": False, + "subagent_model": None, + "env": {}, +} + + +@pytest.fixture() +def flow_module(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> types.ModuleType: + """Import flow_server with a tmp manifest + known orchestrator/SDK URLs.""" + manifest_path = tmp_path / "tool-manifest.json" + manifest_path.write_text(json.dumps(_FULL_MANIFEST)) + + monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000099") + monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer") + monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000") + monkeypatch.setenv("ROBOCO_SDK_URL", "http://test-sdk:9000") + monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path)) + + import roboco.mcp.flow_server as srv + + importlib.reload(srv) + return srv + + +def _make_client( + orchestrator_response: dict[str, Any], sdk_response: dict[str, Any] | None +): + """Build an httpx.Client mock that dispatches by destination URL. + + Calls hitting ``test-orchestrator`` return ``orchestrator_response``; + calls hitting ``test-sdk`` return ``sdk_response``. The mock also + records every URL+body it sees so assertions can verify the SDK was + (or wasn't) called. + """ + captured: list[tuple[str, dict[str, Any] | None]] = [] + + def _client_factory(*_args: Any, **_kwargs: Any) -> MagicMock: + client = MagicMock() + client.__enter__ = MagicMock(return_value=client) + client.__exit__ = MagicMock(return_value=False) + + def _post(url: str, **kwargs: Any) -> MagicMock: + captured.append((url, kwargs.get("json"))) + resp = MagicMock() + if "test-sdk" in url: + if sdk_response is None: + raise AssertionError("SDK called unexpectedly") + resp.json.return_value = sdk_response + else: + resp.json.return_value = orchestrator_response + return resp + + client.post.side_effect = _post + return client + + return _client_factory, captured + + +# --------------------------------------------------------------------------- +# Successful envelope — SDK must NOT be called +# --------------------------------------------------------------------------- + + +def test_ok_envelope_does_not_touch_sdk(flow_module: types.ModuleType) -> None: + """An envelope with error=None never POSTs to /verb/attempted.""" + factory, captured = _make_client( + orchestrator_response={ + "status": "awaiting_qa", + "task_id": "task-A", + "next": "wait", + "error": None, + }, + sdk_response=None, # blow up if SDK is called + ) + + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A", notes="done") + + assert result["status"] == "awaiting_qa" + assert result["error"] is None + # Only the orchestrator was contacted. + assert all("test-sdk" not in url for url, _ in captured) + + +# --------------------------------------------------------------------------- +# Rejection envelope — SDK gets the verb + task_id + rejection_kind +# --------------------------------------------------------------------------- + + +def test_rejection_forwards_to_sdk(flow_module: types.ModuleType) -> None: + """A tracing_gap envelope triggers POST /verb/attempted with the right keys.""" + factory, captured = _make_client( + orchestrator_response={ + "error": "tracing_gap", + "missing": ["pr_number"], + "remediate": "open the PR", + }, + sdk_response={ + "verb": "i_am_done", + "task_id": "task-A", + "attempts": 1, + "limit": 3, + "window_seconds": 60, + "open": False, + "circuit_envelope": None, + }, + ) + + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A", notes="done") + + # Original rejection survives — breaker is not yet open. + assert result["error"] == "tracing_gap" + # Check the SDK saw the right payload. + sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] + assert len(sdk_calls) == 1 + sdk_url, sdk_body = sdk_calls[0] + assert sdk_url.endswith("/verb/attempted") + assert sdk_body == { + "verb": "i_am_done", + "task_id": "task-A", + "rejection_kind": "tracing_gap", + } + + +@pytest.mark.parametrize( + "rejection_kind", + ["tracing_gap", "invalid_state", "not_authorized", "incomplete_input"], +) +def test_all_counted_rejection_kinds_forwarded( + flow_module: types.ModuleType, rejection_kind: str +) -> None: + """All four counted error kinds forward to the SDK.""" + factory, captured = _make_client( + orchestrator_response={ + "error": rejection_kind, + "message": "no", + "remediate": "fix", + }, + sdk_response={ + "verb": "i_am_done", + "task_id": "task-A", + "attempts": 1, + "limit": 3, + "window_seconds": 60, + "open": False, + "circuit_envelope": None, + }, + ) + + with patch("httpx.Client", side_effect=factory): + flow_module.i_am_done("task-A") + + sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] + assert len(sdk_calls) == 1 + _, sdk_body = sdk_calls[0] + assert sdk_body["rejection_kind"] == rejection_kind + + +def test_other_error_kinds_do_not_touch_sdk(flow_module: types.ModuleType) -> None: + """not_found / transport_error aren't counted — SDK is not called.""" + factory, captured = _make_client( + orchestrator_response={"error": "not_found", "message": "no task"}, + sdk_response=None, + ) + + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A") + + assert result["error"] == "not_found" + assert all("test-sdk" not in url for url, _ in captured) + + +# --------------------------------------------------------------------------- +# Breaker open — envelope substituted with circuit_open +# --------------------------------------------------------------------------- + + +def test_breaker_open_substitutes_envelope(flow_module: types.ModuleType) -> None: + """When SDK reports open=true, the agent gets the circuit_open envelope.""" + circuit_env = { + "error": "circuit_open", + "message": ("verb 'i_am_done' rejected 3 times in last 60s — breaker open"), + "remediate": "call i_am_blocked or i_am_idle", + "context_briefing": {}, + "status": None, + "task_id": None, + "next": None, + "evidence": {}, + "correlation_id": None, + "current_state": None, + "valid_next_verbs": None, + } + factory, _ = _make_client( + orchestrator_response={"error": "tracing_gap", "remediate": "x"}, + sdk_response={ + "verb": "i_am_done", + "task_id": "task-A", + "attempts": 3, + "limit": 3, + "window_seconds": 60, + "open": True, + "circuit_envelope": circuit_env, + }, + ) + + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A") + + # The original tracing_gap is GONE — replaced by circuit_open. + assert result["error"] == "circuit_open" + assert "i_am_blocked" in result["remediate"] + + +def test_fourth_rejection_returns_circuit_open(flow_module: types.ModuleType) -> None: + """Hitting the cap on the Nth call yields circuit_open on that same call. + + The SDK records the attempt FIRST and reports open=true on the + response that just pushed it over the threshold — so the call that + trips the breaker is also the call that sees the substitution. + """ + # On the trip call the SDK reports open=true with the envelope. + circuit_env = { + "error": "circuit_open", + "message": ("verb 'i_am_done' rejected 3 times in last 60s — breaker open"), + "remediate": "call i_am_blocked(reason='...') or i_am_idle()", + "context_briefing": {}, + "status": None, + "task_id": None, + "next": None, + "evidence": {}, + "correlation_id": None, + "current_state": None, + "valid_next_verbs": None, + } + factory, captured = _make_client( + orchestrator_response={"error": "tracing_gap", "remediate": "x"}, + sdk_response={ + "verb": "i_am_done", + "task_id": "task-A", + "attempts": 3, + "limit": 3, + "window_seconds": 60, + "open": True, + "circuit_envelope": circuit_env, + }, + ) + + with patch("httpx.Client", side_effect=factory): + result = flow_module.i_am_done("task-A") + + # Substituted envelope shown to the agent. + assert result["error"] == "circuit_open" + # Both calls happened: orchestrator (the verb) then SDK (record). + urls = [url for url, _ in captured] + assert any("test-orchestrator" in u for u in urls) + assert any("test-sdk" in u for u in urls) + + +# --------------------------------------------------------------------------- +# Fail-open behaviour — SDK down must not break the gateway path +# --------------------------------------------------------------------------- + + +def test_sdk_unreachable_fails_open(flow_module: types.ModuleType) -> None: + """If the SDK raises, the agent still sees the original rejection.""" + import httpx + + def _client_factory(*_args: Any, **_kwargs: Any) -> MagicMock: + client = MagicMock() + client.__enter__ = MagicMock(return_value=client) + client.__exit__ = MagicMock(return_value=False) + + def _post(url: str, **_kwargs: Any) -> MagicMock: + if "test-sdk" in url: + raise httpx.ConnectError("SDK down") + resp = MagicMock() + resp.json.return_value = { + "error": "tracing_gap", + "missing": ["pr_number"], + "remediate": "open the PR first", + } + return resp + + client.post.side_effect = _post + return client + + with patch("httpx.Client", side_effect=_client_factory): + result = flow_module.i_am_done("task-A") + + # SDK was unreachable — original envelope passes through. + assert result["error"] == "tracing_gap" + assert result["remediate"] == "open the PR first" + + +def test_sdk_returns_malformed_json_fails_open(flow_module: types.ModuleType) -> None: + """If the SDK responds with un-JSON-able body, the original envelope wins.""" + + def _client_factory(*_args: Any, **_kwargs: Any) -> MagicMock: + client = MagicMock() + client.__enter__ = MagicMock(return_value=client) + client.__exit__ = MagicMock(return_value=False) + + def _post(url: str, **_kwargs: Any) -> MagicMock: + resp = MagicMock() + if "test-sdk" in url: + resp.json.side_effect = ValueError("not json") + else: + resp.json.return_value = { + "error": "invalid_state", + "message": "wrong state", + "remediate": "transition first", + } + return resp + + client.post.side_effect = _post + return client + + with patch("httpx.Client", side_effect=_client_factory): + result = flow_module.i_am_done("task-A") + + assert result["error"] == "invalid_state" + assert result["message"] == "wrong state" + + +# --------------------------------------------------------------------------- +# Verb extraction +# --------------------------------------------------------------------------- + + +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" + ) + assert flow_module._verb_from_path("/api/v2/flow/qa/pass") == "pass" + assert ( + flow_module._verb_from_path("/api/v2/flow/board/escalate_to_ceo") + == "escalate_to_ceo" + ) + + +# --------------------------------------------------------------------------- +# task_id pass-through +# --------------------------------------------------------------------------- + + +def test_task_id_none_is_forwarded_as_null(flow_module: types.ModuleType) -> None: + """Verbs without a task_id (e.g. give_me_work) post task_id=None to the SDK.""" + factory, captured = _make_client( + orchestrator_response={"error": "tracing_gap", "remediate": "x"}, + sdk_response={ + "verb": "give_me_work", + "task_id": None, + "attempts": 1, + "limit": None, # unlimited verb + "window_seconds": 60, + "open": False, + "circuit_envelope": None, + }, + ) + + with patch("httpx.Client", side_effect=factory): + flow_module.give_me_work() + + sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url] + assert len(sdk_calls) == 1 + _, sdk_body = sdk_calls[0] + assert sdk_body["task_id"] is None + assert sdk_body["verb"] == "give_me_work" diff --git a/tests/unit/models/test_task_create_completeness.py b/tests/unit/models/test_task_create_completeness.py new file mode 100644 index 00000000..498ecafa --- /dev/null +++ b/tests/unit/models/test_task_create_completeness.py @@ -0,0 +1,56 @@ +"""TaskCreate (POST /tasks request schema) enforces TASK_AT_CREATE.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from roboco.models.task import TaskCreate + + +def _ok_payload() -> dict: + return { + "title": "Add user lookup endpoint", + "description": ( + "Add GET /v1/users/{id} returning user JSON for the dashboard." + ), + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "team": "backend", + "project_id": uuid4(), + "acceptance_criteria": ["returns 404 for unknown user"], + } + + +def test_task_create_accepts_complete_payload() -> None: + TaskCreate(**_ok_payload()) + + +def test_task_create_rejects_empty_acceptance_criteria() -> None: + payload = _ok_payload() + payload["acceptance_criteria"] = [] + with pytest.raises(ValidationError): + TaskCreate(**payload) + + +def test_task_create_rejects_missing_nature() -> None: + payload = _ok_payload() + del payload["nature"] + with pytest.raises(ValidationError): + TaskCreate(**payload) + + +def test_task_create_rejects_missing_task_type() -> None: + payload = _ok_payload() + del payload["task_type"] + with pytest.raises(ValidationError): + TaskCreate(**payload) + + +def test_task_create_rejects_short_description() -> None: + payload = _ok_payload() + payload["description"] = "x" + with pytest.raises(ValidationError): + TaskCreate(**payload) diff --git a/tests/unit/services/test_notification.py b/tests/unit/services/test_notification.py index 3971eb33..79cc1651 100644 --- a/tests/unit/services/test_notification.py +++ b/tests/unit/services/test_notification.py @@ -196,6 +196,13 @@ async def test_send_qa_failed_notification(svc: NotificationService) -> None: @pytest.mark.asyncio async def test_send_a2a_notification(svc: NotificationService) -> None: + """priority=URGENT writes the row + the [URGENT] cosmetic prefix. + + Pre-P3-Task-9 this used `urgent: True`; the contract is now a + tristate `priority` so HIGH can survive end-to-end. See + tests/integration/test_a2a_priority_tristate.py for the full + HIGH/NORMAL coverage. + """ aid = uuid4() db = _FakeDb(agent_uuid=aid) with _patch_db_context(db): @@ -206,11 +213,12 @@ async def test_send_a2a_notification(svc: NotificationService) -> None: "to_agent": "fe-dev-1", "skill": "react", "message": "hi", - "urgent": True, + "priority": NotificationPriority.URGENT, }, ) # Urgent prefix appears in subject. assert any("URGENT" in row.subject for row in db.added) + assert any(row.priority == NotificationPriority.URGENT for row in db.added) @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 561bc550..6100c77f 100644 --- a/uv.lock +++ b/uv.lock @@ -704,115 +704,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" +version = "7.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, - { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, - { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, - { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, - { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, + { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, + { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] [package.optional-dependencies] @@ -1534,11 +1534,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, ] [[package]] @@ -1867,87 +1867,87 @@ wheels = [ [[package]] name = "librt" -version = "0.10.0" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/18/827e5c1262a88c2602e86f99aee0f288ffea3280dbd2ff448858ef9dc6e9/librt-0.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dc99f9642100b86e5f6bb14cdc9970009e31a9ef7d64df6704b7018451524a3", size = 76461, upload-time = "2026-05-05T16:29:00.422Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/54254e30287f5a5abec6fef22d976987476e966be5fdff51fe8c2d5d73d1/librt-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8298cedfcfaff3790000bd057aaaa3df1b0ab54cf7b48eeab16184cbb1bc66b9", size = 79740, upload-time = "2026-05-05T16:29:01.926Z" }, - { url = "https://files.pythonhosted.org/packages/4c/20/e93264b52113669d98d3b63ff94d4ce0c4dd49ae0503f1788440a884e5f0/librt-0.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7dbe312dbf76468255b79a7ba311236fde620f2f7055fc09d421e31340314e", size = 243472, upload-time = "2026-05-05T16:29:03.373Z" }, - { url = "https://files.pythonhosted.org/packages/35/ad/34a5141178e8b18a4cfa45d1a0d523c84397e2abd5d06fea2d846da687e8/librt-0.10.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:56ed90c48c19249012dadfd79a1bc13bd5168ea60a70722d330a3a600c0b1852", size = 232073, upload-time = "2026-05-05T16:29:04.815Z" }, - { url = "https://files.pythonhosted.org/packages/97/1f/67240e910cd9f9ab1498c1470738345fc29dce5dc9719db1e0e09d1e861f/librt-0.10.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d74ca0f4b2b09c117f913d4df01f6b934dff8a271096b35167d5264a31649f0", size = 256956, upload-time = "2026-05-05T16:29:06.516Z" }, - { url = "https://files.pythonhosted.org/packages/22/50/3a2b3482c27d607f6e8216d913c6bc592b9a2141d96990309452340a78e3/librt-0.10.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8eb2daa9375f93c0e55ff5e44a4bbe98f39e5fe52e1abf9c97acb67743b61bf8", size = 250593, upload-time = "2026-05-05T16:29:08.324Z" }, - { url = "https://files.pythonhosted.org/packages/e7/1c/07dba133d79f93322fa17514062f1a2a50d6bdfb7baec4acf78193d7fad1/librt-0.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7b09b90e634e6dff57978cd358070046071e2b120501f10787aeb35425f504f6", size = 263582, upload-time = "2026-05-05T16:29:09.866Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ac/033f2c6d6ab0b48f15f02e5bf065521b11a51922806017f8b6274df30d69/librt-0.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2cf22fd379d60c739b800d4295ed34045f8b04aa8df9c12bd2f8f43f7fe672b7", size = 259307, upload-time = "2026-05-05T16:29:11.675Z" }, - { url = "https://files.pythonhosted.org/packages/6e/10/679046cd75d5a52c0104c890d8f69574ef4e619c683e59c15584d03a2457/librt-0.10.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:74c798793fcf29a84d442278ebe0bb1fff79fe58ac4106eeff7019cbba861423", size = 257342, upload-time = "2026-05-05T16:29:13.14Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d5/dbaac9c0884f78a53dda22b9ec92bb788e1400e762ed7623fa96928c8da5/librt-0.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc4f1573401e8dbe6c26511fe027620b0fb30ae9a7ab814e02e510626b8b5f9c", size = 280141, upload-time = "2026-05-05T16:29:14.922Z" }, - { url = "https://files.pythonhosted.org/packages/cc/81/71f18cf8eb340d9fda011498870910f6a8697aeb50833005d3d8107653fd/librt-0.10.0-cp310-cp310-win32.whl", hash = "sha256:e1428275f5fe3d4db6822e58d8b005a5b28ffca55e8433ebc051247fbe46429f", size = 62257, upload-time = "2026-05-05T16:29:16.226Z" }, - { url = "https://files.pythonhosted.org/packages/df/52/6bcebc2f870c4836bcb372be885fae7f17a1d25037d3a8250ef79fbe0124/librt-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:0708e9408f585b0f065081680583a577652099680ccf820c7538904322b679c3", size = 70321, upload-time = "2026-05-05T16:29:17.41Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a3/1472717d2325adacc8d335ba2e4078015c09d75b599f3cf48e967b3d306e/librt-0.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01b4500ca3a625450c032a9142a8e843923ce263fa8a92ad1b38927cabe2fe72", size = 76045, upload-time = "2026-05-05T16:29:18.731Z" }, - { url = "https://files.pythonhosted.org/packages/a6/31/bfe32355d4b369aef3d7aa442df663bb5558c2ffa2de286cb2956346bc24/librt-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6b7e42d1b3e300d20bfc87e72ffd62f0a92a2cb3c35f7bf90df90c9d2a49f74c", size = 79466, upload-time = "2026-05-05T16:29:20.052Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f1/83f8a2c715ba2cac9b7387a5a5cea25f717f7184320cfe48b36bed9c58e9/librt-0.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8ef7b8c61ce3a1b597cd3e15348ff1574325165c2e7ce09a718154cde2a7950", size = 242283, upload-time = "2026-05-05T16:29:21.596Z" }, - { url = "https://files.pythonhosted.org/packages/cc/94/c3a4ce94857f0004a542f86662806383611858f522722db58efaec0a1472/librt-0.10.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e73c84f72d1fa0d6eaa7a1930b436ba8d2c90c58d77bfabb09995a69ad35f6c0", size = 230735, upload-time = "2026-05-05T16:29:23.335Z" }, - { url = "https://files.pythonhosted.org/packages/d1/41/e962bb26c7728eb7b3a69e490d0c800fd9968a6970e390c1f18ddb56093d/librt-0.10.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9728cb98713bd862fb8f4fd6a642d1896c86058a41d77c70f3d5cee75e725275", size = 256606, upload-time = "2026-05-05T16:29:24.91Z" }, - { url = "https://files.pythonhosted.org/packages/66/3a/4e46a707b1ecc993fd691071623b9beab89703a63bd21cc7807e06c28209/librt-0.10.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:648b7e941d20acd72f9652115e0e53facd98156d61f9ebf7a812bdef8bdccea9", size = 249739, upload-time = "2026-05-05T16:29:26.648Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f5/dc5b7eb294656ad23d4ff4cf8514208d54fe1026b909d726a0dc026689c9/librt-0.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c3e33747c068e86a9007c20fdb777eb5ba8d3d19136d7812f88e69a713041b6f", size = 261414, upload-time = "2026-05-05T16:29:28.702Z" }, - { url = "https://files.pythonhosted.org/packages/58/e4/990ed8d12c7f114ac8f8ccd47f7d9bd9704ef61acfcb1df4a05047da7710/librt-0.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d509c745bf7e77d1107cf05e6abb249dc03fad13eb39f2286a49deedaeb2bcd7", size = 256614, upload-time = "2026-05-05T16:29:30.357Z" }, - { url = "https://files.pythonhosted.org/packages/60/eb/52d2726c7fb22818507dc3cc166c8f36dd4a4b68a7be67f12006ac8777c1/librt-0.10.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:786ad5a15e99d0e0e74f3adbeecc198a5ac58f340be07e984723d1e0074838de", size = 255144, upload-time = "2026-05-05T16:29:32.106Z" }, - { url = "https://files.pythonhosted.org/packages/bc/df/bd5591a78f7531fce4b6eb9962aadc6adc9560a01570442a884b6e554abe/librt-0.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:075582d877a97ee3d8e77bda3689dbe617b14f6469224a2d80b4b6c38e3951aa", size = 279121, upload-time = "2026-05-05T16:29:33.688Z" }, - { url = "https://files.pythonhosted.org/packages/fd/df/7c2b838dfc89a1762dd156d8b0c39848a7a2845d725a50be5a6e021fb8ba/librt-0.10.0-cp311-cp311-win32.whl", hash = "sha256:75ecdc3f5a90065aa2af2e574706c5495adc392520762dcf10b1aa716f0b8090", size = 62593, upload-time = "2026-05-05T16:29:35.152Z" }, - { url = "https://files.pythonhosted.org/packages/91/19/22ff572981049a9d436a083dbea1572d0f5dc068b7353637d2dd9977c8f1/librt-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:b6f6084884131d8a52cb9d7095ff2aa52c1e786d9fdaefab1fb4515415e9e083", size = 70914, upload-time = "2026-05-05T16:29:36.407Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/1697cc64f4a5c7e9bce55e99c6d234a346beaedaefcd1e2ca90dd285f98c/librt-0.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:0140bd62151160047e89b2730cb6f8506cdac5127baa1afb9231e4dd3fe7f681", size = 61176, upload-time = "2026-05-05T16:29:37.62Z" }, - { url = "https://files.pythonhosted.org/packages/12/8e/cbb5b6f6e45e65c10a42449a69eaccc44d73e6a081ea752fbc5221c6dc1c/librt-0.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b4b58a44b407e91f633dafee008de9ddea6aa2a555ed94929c099260910bd0ba", size = 77327, upload-time = "2026-05-05T16:29:38.919Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3d/8233cbee8e99e6a8992f02bfc2dec8d787509566a511d1fde2574ee7473f/librt-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:950b79b11762531bdf45a9df909d2f9a2a8445c70c88665c01d14c8511a27dc5", size = 79971, upload-time = "2026-05-05T16:29:40.96Z" }, - { url = "https://files.pythonhosted.org/packages/87/6f/5264b298cef2b72fc97d2dde56c66181eda35204bf5dcd1ed0c3d0a0a782/librt-0.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4538453f51be197633b425912c150e25b0667252d3741c53e8368176d98d9d37", size = 246559, upload-time = "2026-05-05T16:29:42.701Z" }, - { url = "https://files.pythonhosted.org/packages/07/7b/19b1b859cc60d5f99276cc2b3144d91556c6d1b1e4ebb50359696bebf7a8/librt-0.10.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:70b955f091beac93e994a0b7ec616934f63b3ea5c3d6d7af847562f935aceca7", size = 235216, upload-time = "2026-05-05T16:29:44.193Z" }, - { url = "https://files.pythonhosted.org/packages/6e/56/a2f40717142a8af46289f57874ef914353d8faccd5e4f8e594ab1e16e8c7/librt-0.10.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:483e685e06b6163728ba6c85d74315176be7190f432ec2a41226e5e14355d5f0", size = 263108, upload-time = "2026-05-05T16:29:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/67/ca/15c625c3bdc0167c01e04ef8878317e9713f3bfa788438342f7a94c7b22c/librt-0.10.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ac53d946a009d1a38c44a60812708c9458fb2a239a5f630d8e625571386650f", size = 255280, upload-time = "2026-05-05T16:29:48.087Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/ba301d571d9e05844e2435b73aba30bee77bb75ce155c9affcfd2173dd03/librt-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc8771c9fcf0ea894ca41fdc2abd83572c2fbda221f232d86e718614e57ff513", size = 268829, upload-time = "2026-05-05T16:29:49.628Z" }, - { url = "https://files.pythonhosted.org/packages/8b/60/af70e135bc1f1fe15dd3894b1e4bbefc7ecdf911749a925a39eb86ceb2a1/librt-0.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:70805dbc5257892ac572f86290a61e3c8d90224ecce1a8b2d1f7ed51965417f4", size = 262051, upload-time = "2026-05-05T16:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/83/c2/c8236eb8b421bac5a172ba208f965abaa89805da2a3fa112bdf1764caf8f/librt-0.10.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d3b4f300f7bcba6e2ff73fb8bef1898479e9772bfa2682998c636391633ec826", size = 264347, upload-time = "2026-05-05T16:29:53.013Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/15b6d32bc25dacd4a60886a683d8128d6219910c122202b995a40dd4f8d2/librt-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:943bc943f92f4fb3408fae62485c6a3ad68ce4f2ee205643a39641525c19a276", size = 286482, upload-time = "2026-05-05T16:29:54.675Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8e/b1b959bacd323eb4360579db992513e1406d1c6ef7edb57b5511fd0666fd/librt-0.10.0-cp312-cp312-win32.whl", hash = "sha256:6065c1a758fba1010b41401013903d3d5d2750eab425ddedd584abac31d0630e", size = 62955, upload-time = "2026-05-05T16:29:56.39Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4c/d4cd6e4b9fc24098e63cc85537d1b6689682aee96809c38f08072067cc2b/librt-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:d788ecbe208ab352dab0e105cc06057bf9a2fc7e58cabb0d751ad9e30062b9e2", size = 71191, upload-time = "2026-05-05T16:29:57.682Z" }, - { url = "https://files.pythonhosted.org/packages/2b/19/8641da1f63d24b92354a492f893c022d6b3a0df44e70c8eff49364613983/librt-0.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:6003d1f295bdba02656dc81308208fc060d0a51d8c0d0a6db70f7f3c57b9ba0a", size = 61432, upload-time = "2026-05-05T16:29:58.971Z" }, - { url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" }, - { url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" }, - { url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" }, - { url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" }, - { url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" }, - { url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" }, - { url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" }, - { url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" }, - { url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" }, - { url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" }, - { url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" }, - { url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" }, - { url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" }, - { url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" }, - { url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" }, - { url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" }, - { url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" }, - { url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" }, - { url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" }, + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] @@ -2292,19 +2292,19 @@ wheels = [ [[package]] name = "matplotlib-inline" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] [[package]] name = "mcp" -version = "1.27.0" +version = "1.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2322,9 +2322,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, ] [[package]] @@ -3720,16 +3720,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.0" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] @@ -3936,11 +3936,11 @@ cryptography = [ [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.28" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, ] [[package]] @@ -4115,123 +4115,123 @@ wheels = [ [[package]] name = "regex" -version = "2026.4.4" +version = "2026.5.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/59/fd98f8fd54b3feaa76a855324c676c17668c5a1121ec91b7ec96b01bf865/regex-2026.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:74fa82dcc8143386c7c0392e18032009d1db715c25f4ba22d23dc2e04d02a20f", size = 489403, upload-time = "2026-04-03T20:52:39.742Z" }, - { url = "https://files.pythonhosted.org/packages/6c/64/d0f222f68e3579d50babf0e4fcc9c9639ef0587fecc00b15e1e46bfc32fa/regex-2026.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a85b620a388d6c9caa12189233109e236b3da3deffe4ff11b84ae84e218a274f", size = 291208, upload-time = "2026-04-03T20:52:42.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/7f/3fab9709b0b0060ba81a04b8a107b34147cd14b9c5551b772154d6505504/regex-2026.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2895506ebe32cc63eeed8f80e6eae453171cfccccab35b70dc3129abec35a5b8", size = 289214, upload-time = "2026-04-03T20:52:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/14/bc/f5dcf04fd462139dcd75495c02eee22032ef741cfa151386a39c3f5fc9b5/regex-2026.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6780f008ee81381c737634e75c24e5a6569cc883c4f8e37a37917ee79efcafd9", size = 785505, upload-time = "2026-04-03T20:52:46.35Z" }, - { url = "https://files.pythonhosted.org/packages/37/36/8a906e216d5b4de7ec3788c1d589b45db40c1c9580cd7b326835cfc976d4/regex-2026.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88e9b048345c613f253bea4645b2fe7e579782b82cac99b1daad81e29cc2ed8e", size = 852129, upload-time = "2026-04-03T20:52:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/a5/bb/bad2d79be0917a6ef31f5e0f161d9265cb56fd90a3ae1d2e8d991882a48b/regex-2026.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:be061028481186ba62a0f4c5f1cc1e3d5ab8bce70c89236ebe01023883bc903b", size = 899578, upload-time = "2026-04-03T20:52:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b9/7cd0ceb58cd99c70806241636640ae15b4a3fe62e22e9b99afa67a0d7965/regex-2026.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d2228c02b368d69b724c36e96d3d1da721561fb9cc7faa373d7bf65e07d75cb5", size = 793634, upload-time = "2026-04-03T20:52:53Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fb/c58e3ea40ed183806ccbac05c29a3e8c2f88c1d3a66ed27860d5cad7c62d/regex-2026.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0540e5b733618a2f84e9cb3e812c8afa82e151ca8e19cf6c4e95c5a65198236f", size = 786210, upload-time = "2026-04-03T20:52:54.713Z" }, - { url = "https://files.pythonhosted.org/packages/54/a9/53790fc7a6c948a7be2bc7214fd9cabdd0d1ba561b0f401c91f4ff0357f0/regex-2026.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf9b1b2e692d4877880388934ac746c99552ce6bf40792a767fd42c8c99f136d", size = 769930, upload-time = "2026-04-03T20:52:56.825Z" }, - { url = "https://files.pythonhosted.org/packages/e3/3c/29ca44729191c79f5476538cd0fa04fa2553b3c45508519ecea4c7afa8f6/regex-2026.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:011bb48bffc1b46553ac704c975b3348717f4e4aa7a67522b51906f99da1820c", size = 774892, upload-time = "2026-04-03T20:52:58.934Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/6ae74ef8a4cfead341c367e4eed45f71fb1aaba35827a775eed4f1ba4f74/regex-2026.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8512fcdb43f1bf18582698a478b5ab73f9c1667a5b7548761329ef410cd0a760", size = 848816, upload-time = "2026-04-03T20:53:00.684Z" }, - { url = "https://files.pythonhosted.org/packages/53/9a/f7f2c1c6b610d7c6de1c3dc5951effd92c324b1fde761af2044b4721020f/regex-2026.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:867bddc63109a0276f5a31999e4c8e0eb7bbbad7d6166e28d969a2c1afeb97f9", size = 758363, upload-time = "2026-04-03T20:53:02.155Z" }, - { url = "https://files.pythonhosted.org/packages/dd/55/e5386d393bbf8b43c8b084703a46d635e7b2bdc6e0f5909a2619ea1125f1/regex-2026.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1b9a00b83f3a40e09859c78920571dcb83293c8004079653dd22ec14bbfa98c7", size = 837122, upload-time = "2026-04-03T20:53:03.727Z" }, - { url = "https://files.pythonhosted.org/packages/01/da/cc78710ea2e60b10bacfcc9beb18c67514200ab03597b3b2b319995785c2/regex-2026.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e355be718caf838aa089870259cf1776dc2a4aa980514af9d02c59544d9a8b22", size = 782140, upload-time = "2026-04-03T20:53:05.608Z" }, - { url = "https://files.pythonhosted.org/packages/a2/5f/c7bcba41529105d6c2ca7080ecab7184cd00bee2e1ad1fdea80e618704ea/regex-2026.4.4-cp310-cp310-win32.whl", hash = "sha256:33bfda9684646d323414df7abe5692c61d297dbb0530b28ec66442e768813c59", size = 266225, upload-time = "2026-04-03T20:53:07.342Z" }, - { url = "https://files.pythonhosted.org/packages/eb/26/a745729c2c49354ec4f4bce168f29da932ca01b4758227686cc16c7dde1b/regex-2026.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:0709f22a56798457ae317bcce42aacee33c680068a8f14097430d9f9ba364bee", size = 278393, upload-time = "2026-04-03T20:53:08.65Z" }, - { url = "https://files.pythonhosted.org/packages/87/8b/4327eeb9dbb4b098ebecaf02e9f82b79b6077beeb54c43d9a0660cf7c44c/regex-2026.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:ee9627de8587c1a22201cb16d0296ab92b4df5cdcb5349f4e9744d61db7c7c98", size = 270470, upload-time = "2026-04-03T20:53:10.018Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7a/617356cbecdb452812a5d42f720d6d5096b360d4a4c1073af700ea140ad2/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", size = 489415, upload-time = "2026-04-03T20:53:11.645Z" }, - { url = "https://files.pythonhosted.org/packages/20/e6/bf057227144d02e3ba758b66649e87531d744dda5f3254f48660f18ae9d8/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", size = 291205, upload-time = "2026-04-03T20:53:13.289Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3b/637181b787dd1a820ba1c712cee2b4144cd84a32dc776ca067b12b2d70c8/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", size = 289225, upload-time = "2026-04-03T20:53:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/05/21/bac05d806ed02cd4b39d9c8e5b5f9a2998c94c3a351b7792e80671fa5315/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", size = 792434, upload-time = "2026-04-03T20:53:17.414Z" }, - { url = "https://files.pythonhosted.org/packages/d9/17/c65d1d8ae90b772d5758eb4014e1e011bb2db353fc4455432e6cc9100df7/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", size = 861730, upload-time = "2026-04-03T20:53:18.903Z" }, - { url = "https://files.pythonhosted.org/packages/ad/64/933321aa082a2c6ee2785f22776143ba89840189c20d3b6b1d12b6aae16b/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", size = 906495, upload-time = "2026-04-03T20:53:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/01/ea/4c8d306e9c36ac22417336b1e02e7b358152c34dc379673f2d331143725f/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", size = 799810, upload-time = "2026-04-03T20:53:22.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/ce/7605048f00e1379eba89d610c7d644d8f695dc9b26d3b6ecfa3132b872ff/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", size = 774242, upload-time = "2026-04-03T20:53:25.015Z" }, - { url = "https://files.pythonhosted.org/packages/e9/77/283e0d5023fde22cd9e86190d6d9beb21590a452b195ffe00274de470691/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", size = 781257, upload-time = "2026-04-03T20:53:26.918Z" }, - { url = "https://files.pythonhosted.org/packages/8b/fb/7f3b772be101373c8626ed34c5d727dcbb8abd42a7b1219bc25fd9a3cc04/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", size = 854490, upload-time = "2026-04-03T20:53:29.065Z" }, - { url = "https://files.pythonhosted.org/packages/85/30/56547b80f34f4dd2986e1cdd63b1712932f63b6c4ce2f79c50a6cd79d1c2/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", size = 763544, upload-time = "2026-04-03T20:53:30.917Z" }, - { url = "https://files.pythonhosted.org/packages/ac/2f/ce060fdfea8eff34a8997603532e44cdb7d1f35e3bc253612a8707a90538/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", size = 844442, upload-time = "2026-04-03T20:53:32.463Z" }, - { url = "https://files.pythonhosted.org/packages/e5/44/810cb113096a1dacbe82789fbfab2823f79d19b7f1271acecb7009ba9b88/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", size = 789162, upload-time = "2026-04-03T20:53:34.039Z" }, - { url = "https://files.pythonhosted.org/packages/20/96/9647dd7f2ecf6d9ce1fb04dfdb66910d094e10d8fe53e9c15096d8aa0bd2/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", size = 266227, upload-time = "2026-04-03T20:53:35.601Z" }, - { url = "https://files.pythonhosted.org/packages/33/80/74e13262460530c3097ff343a17de9a34d040a5dc4de9cf3a8241faab51c/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", size = 278399, upload-time = "2026-04-03T20:53:37.021Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/39f19f47f19dcefa3403f09d13562ca1c0fd07ab54db2bc03148f3f6b46a/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", size = 270473, upload-time = "2026-04-03T20:53:38.633Z" }, - { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, - { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, - { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, - { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, - { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, - { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, - { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, - { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, - { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, - { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, - { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, - { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, - { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, - { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, - { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, - { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, - { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, - { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, - { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, - { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, - { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, - { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, - { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, - { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, - { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, - { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, - { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, - { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, - { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, - { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, - { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, - { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/0ad2c8edf634918eb4484365d3819fa7bd7f58daf807fe7fb21812c316e5/regex-2026.5.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44", size = 489438, upload-time = "2026-05-09T23:11:29.374Z" }, + { url = "https://files.pythonhosted.org/packages/89/a9/4ed972ad263963b860b7c3e86e0e1bcc791def47b43b8c8efe57e710f139/regex-2026.5.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a", size = 291270, upload-time = "2026-05-09T23:11:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/16/81/075930d9fa28c4ea1f53398dd015ee7c882f623539759113cda1257f4b82/regex-2026.5.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15ee42209947f4ca045412eae98416317238163618ace2a8e54f99586a466733", size = 289198, upload-time = "2026-05-09T23:11:35.769Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c8/5cdfbf0b5dc6599e1b6131eff43262e5275d4ec3469ce10216061659aadb/regex-2026.5.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb445ff3f725f59df8f6014edb547ee928ec7023a774f6a39a3f953038cbb2", size = 784765, upload-time = "2026-05-09T23:11:37.689Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ca/ae5fd6edc59b7f84b904b31d6ec39a860cbcecd10f64bd5a062ca83a4864/regex-2026.5.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446ddd671e43ab535810c4b21cff7104945c701d4a14d1e6d1cd6f4e445a8bea", size = 852115, upload-time = "2026-05-09T23:11:39.973Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ce/a91cf555afb51f3b74a182e24ba073b91ea7bb64592fc4b315c111bb19fd/regex-2026.5.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b92817338591505f282cf3864c145244b1edcf5381d237038df955001091538", size = 899503, upload-time = "2026-05-09T23:11:42.48Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/725a0a2b245a4cf0c4bab29d0e97c74285d94136a65d1b55a6459a583502/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b8a143aca6c39b446ea8092cde25cc8fe9304d4f5fecfbc1a9dbb0282703c2", size = 794093, upload-time = "2026-05-09T23:11:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2a/996efbd59ce6b5d4a09e3af6180ceb62af171f4a9a6fb557d2f0ae0d462b/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0f03aa6898aaaac4592479821df16e68e8d0e29e903e65d8f2dfb2f19028a989", size = 786234, upload-time = "2026-05-09T23:11:46.882Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/8731e8b8806174c9cdd5903f80a14990331c1f42fc4209b540952e9e010d/regex-2026.5.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed457d8e98ae812ed7732bef7bf78de78e834eae0372a74e23ca90ef21d910f9", size = 769895, upload-time = "2026-05-09T23:11:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0b/932473194bd563f342a412ae2ffbbd6da608306a2bc4e99249a41c2b0b92/regex-2026.5.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71b61c5bfe1c806332defc42ad6c780b3c55f661986d7f40283a3a88274b4c00", size = 774991, upload-time = "2026-05-09T23:11:51.261Z" }, + { url = "https://files.pythonhosted.org/packages/98/80/9523d196010031df25f7177ee0a467efbee436324038e5d99def17a57515/regex-2026.5.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3b1e39888c5e0c7d92cea4fc777396c4a90363b05de75d02eb459a4752200808", size = 848790, upload-time = "2026-05-09T23:11:53.232Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/56987b35e89edf47e4a38cf2845aeee476bfa688a6bdbd3e820cda461dc1/regex-2026.5.9-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6ba42b2e7e7f46cf68cc6a5ca36fa07959f9bbd9c6bdcc47b6ee76549a590248", size = 757679, upload-time = "2026-05-09T23:11:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/04/2a/ff713fff0c566507c06a4ce2dc0ae8e7eeebc88811a95fc81cf1e7d534dd/regex-2026.5.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c010eb8caca74bdb40c07498d7ece26b4428fd3f04aa8a72c9ac6f79e8faaac6", size = 837116, upload-time = "2026-05-09T23:11:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/77/90/df6d982b03e3614785c6937ba51b57f6733d97d2ee1c9bc7531dbfab3a54/regex-2026.5.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a6a563446a41adc451393dc6b8e6ad87979efaee3c8738690a8d1b08ebead1b4", size = 782081, upload-time = "2026-05-09T23:11:59.607Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/4e88a5f7c3e98489aac4dd23142723d907b2a595b4a6abcbacabefeded09/regex-2026.5.9-cp310-cp310-win32.whl", hash = "sha256:954cc214c04663ee6d266fc61739cad83054683048de65c5bd1d640ad28098ac", size = 266247, upload-time = "2026-05-09T23:12:01.116Z" }, + { url = "https://files.pythonhosted.org/packages/6a/40/4b224cb0582b2dca1786726e6cdabe26abbf757d7f6718332f186da155d2/regex-2026.5.9-cp310-cp310-win_amd64.whl", hash = "sha256:b310768746dd314ea6e2ff4cc89ef215426813396ff4e94ee8e6f7096c8b6e03", size = 278416, upload-time = "2026-05-09T23:12:03.2Z" }, + { url = "https://files.pythonhosted.org/packages/12/4d/014fbe803204cab0947ee428f09f658a29632053dde1d3c6176bb4f0fd4c/regex-2026.5.9-cp310-cp310-win_arm64.whl", hash = "sha256:19c16ceb4a267a8789e25733e583983eeab9f0f8664e66b0bd1c5d21f14c2d4b", size = 270413, upload-time = "2026-05-09T23:12:04.649Z" }, + { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, + { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, + { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, + { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, ] [[package]] @@ -5420,14 +5420,14 @@ wheels = [ [[package]] name = "types-cffi" -version = "2.0.0.20260506" +version = "2.0.0.20260508" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d0/b54c7338ae45580c56daed5f7d468e359a415b73637affed164f33d83a76/types_cffi-2.0.0.20260506.tar.gz", hash = "sha256:8cf63d7006bf0fec825cc5a70fa637ed783b25ef0d0980d09f27606600123f75", size = 17718, upload-time = "2026-05-06T05:17:56.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/17/dd80304dade64eefae44f273e829734fc01243bf94555e787386c09146a9/types_cffi-2.0.0.20260508.tar.gz", hash = "sha256:746b081b4bf84f9d8855c517a67c2dff717f3c18657fcff8e9c251fb5778f311", size = 17750, upload-time = "2026-05-08T04:51:48.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/cf/b562968181a5374c9bddaf5f543ddd857d1072c578e77f63744d8a8d3a17/types_cffi-2.0.0.20260506-py3-none-any.whl", hash = "sha256:43472f8e31f8dc7abbf0c4119828f79c3f4a048b5edcebd19538ee6cf3ca69ed", size = 20195, upload-time = "2026-05-06T05:17:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/18/02/95d98d4473197da55bb5b9c67f7ff1e49c0a12ca870e29004129f635be18/types_cffi-2.0.0.20260508-py3-none-any.whl", hash = "sha256:d094065daf4edcfbdd3e11c37d2fa9511eaf7c509da7a9d9573c276398a8e745", size = 20174, upload-time = "2026-05-08T04:51:47.548Z" }, ] [[package]] @@ -5488,11 +5488,11 @@ wheels = [ [[package]] name = "types-setuptools" -version = "82.0.0.20260408" +version = "82.0.0.20260508" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/12/3464b410c50420dd4674fa5fe9d3880711c1dbe1a06f5fe4960ee9067b9e/types_setuptools-82.0.0.20260408.tar.gz", hash = "sha256:036c68caf7e672a699f5ebbf914708d40644c14e05298bc49f7272be91cf43d3", size = 44861, upload-time = "2026-04-08T04:29:33.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/53/8c7ca2263165f13b493f5258a317acb09cab02742e816c38cd5fe6f09e5a/types_setuptools-82.0.0.20260508.tar.gz", hash = "sha256:e76ade6f42ba9b4211636b84b65a8e55948a67ffe81f9a44e66b8af93d57e77e", size = 44919, upload-time = "2026-05-08T04:47:48.32Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/e1/46a4fc3ef03aabf5d18bac9df5cf37c6b02c3bddf3e05c3533f4b4588331/types_setuptools-82.0.0.20260408-py3-none-any.whl", hash = "sha256:ece0a215cdfa6463a65fd6f68bd940f39e455729300ddfe61cab1147ed1d2462", size = 68428, upload-time = "2026-04-08T04:29:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/d0/67/f49414a00fc61a4bc64bd0ff879bb230818b68e62c5cf91fc7c098912aac/types_setuptools-82.0.0.20260508-py3-none-any.whl", hash = "sha256:ba1d863bbd11526d7232bca8d5a4aebe1d38fa1677a550f47a2692b7d5776900", size = 68395, upload-time = "2026-05-08T04:47:47.391Z" }, ] [[package]]