Files
roboco/tests/integration/test_task_baseline_constraints.py
2a9d9e25d9 feat(tasks): task-content guardrails — structured plans + constraints split (#328)
* feat(tasks): task-content guardrails — structured plans + constraints split

Bound task PLANNING content the way journals/notes already are, fixing the
poor task quality flagged 2026-07-07 (degenerate roots, over-decomposed
leaves, descriptions bloated by an auto-attached conventions dump).

Phase A — plan/AC guardrails (no migration):
- _pm_sub_tasks_gate: cap sub_tasks at 7; per-subtask ceilings (title <=200,
  description <=600) enforced at both the Pydantic boundary and the gate.
  Dropped the min-2-roots and no-subtasks-on-code rules: both contradict the
  2026-05-08 rule (test_cell_pm_can_plan_code_typed_parent_via_i_will_plan)
  and break legitimate single-cell roots. Long comment in the gate explains.
- IWillPlanRequest: plan <=2000, approach <=800 (floor 150 kept), typed
  SubTaskCreate/RiskCreate/OpenQuestionCreate replacing loose list[dict].
- DelegateRequest + task_completeness: acceptance_criteria capped at 7 items,
  each <=200 chars. New FieldRule.MAX_LENGTH_LIST + _post_rule_reject helper
  (extracted to keep the gate under xenon B).
- Routes dump typed models to dicts for the existing rich_plan shaper.

Phase B — conventions split (migration 068):
- New nullable tasks.constraints Text column; _attach_baseline_constraints
  now writes the ## Constraints block there instead of appending to
  description, so description is the human-authored instruction only. The
  conventions still reach the agent independently at spawn via the ambient
  block, so agent correctness is unaffected.
- TaskResponse / Task model / panel Task type carry constraints; panel shows
  a read-only Constraints card. Field is optional on the TS type (backend
  returns null for flag-off / pre-migration rows).

Tests: 5 new gate unit tests, 7 schema tests, 3 AC policy tests, 3 e2e smoke
scenarios; 4 baseline-constraints integration tests updated. ruff/mypy/xenon
clean; 10026 unit+foundation+e2e green; panel typecheck clean.

Refs: plan breezy-imagining-kahn

* test(tasks): use typed SubTaskCreate instead of dict literals in plan tests

make quality runs mypy over tests/ (1079 files), not just roboco/ — the
four sites passing dict literals to the now-typed sub_tasks: list[SubTaskCreate]
field failed mypy. Construct SubTaskCreate directly; the typed model raising
ValidationError IS the boundary the rejection tests assert.

* fix(deps): drop unused python-jose — clears PYSEC-2026-1325 (ecdsa, no fix)

CI's pip-audit went red on a freshly-published advisory PYSEC-2026-1325
against ecdsa 0.19.2 (no fix published — 0.19.2 is the latest). ecdsa is a
transitive dep of python-jose, which is a DIRECT dep of roboco but is NOT
imported anywhere in roboco/ or tests/ (grep-verified). The actual JWT path
uses PyJWT (import jwt) + fastapi_users.jwt, not python-jose.

So python-jose is a dead dependency. Removing it (deletion over an
--ignore-vuln waiver) drops ecdsa + rsa + pyasn1 + their type stubs from the
lockfile, eliminating the CVE at the source. deptry roboco/ stays clean
(no missing-dep), mypy clean, auth + schema tests pass.

Master CI was green 9h before this PR's run, so the advisory published in
that window would red any run including master — this fix unblocks both.

* chore(prompts): regenerate verb tables for typed plan sub_tasks

Phase A's IWillPlanRequest schema change (sub_tasks/risks/open_questions from
loose list[dict] to typed SubTaskCreate/RiskCreate/OpenQuestionCreate) made
the auto-generated verb tables stale. Regenerated via
scripts/regenerate_verb_tables.py — the diff is purely the signature
reflection (list[str|str] -> list[SubTaskCreate], etc.). Required by the
foundation-check gate (Makefile:559).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-08 02:01:23 +02:00

118 lines
4.1 KiB
Python

"""TaskService.create appends the project's baseline constraints (flag-gated)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskNature
from roboco.models.task import TaskCreateRequest, TaskType
from roboco.services.task import TaskService
if TYPE_CHECKING:
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed(db: AsyncSession) -> tuple[AgentTable, ProjectTable]:
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.add(agent)
await db.flush()
project = ProjectTable(
id=uuid4(),
name="C-Proj",
slug=f"c-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db.add(project)
await db.flush()
return agent, project
def _req(
agent: AgentTable, project: ProjectTable, description: str
) -> TaskCreateRequest:
return TaskCreateRequest(
title="A task",
description=description,
acceptance_criteria=["it works"],
team=Team.BACKEND,
created_by=UUID(str(agent.id)),
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=UUID(str(project.id)),
)
async def test_baseline_attached_when_flag_on(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
agent, project = await _seed(db_session)
task = await TaskService(db_session).create(_req(agent, project, "Do the work"))
# The conventions block lives in `constraints`, not `description` (the
# 2026-07-07 fix: description is the human-authored instruction only).
assert task.description == "Do the work"
assert task.constraints is not None
assert "## Constraints" in task.constraints
assert "no lint suppressions" in task.constraints
async def test_flag_off_attaches_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
agent, project = await _seed(db_session)
task = await TaskService(db_session).create(_req(agent, project, "Do the work"))
assert task.description == "Do the work"
assert task.constraints is None
async def test_baseline_not_suppressed_by_agent_constraints_section(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
# An agent-authored ## Constraints section in the description must NOT
# suppress the mandatory server baseline — the description keeps the
# agent's block and `constraints` carries the server baseline (separate
# field, so neither suppresses the other).
monkeypatch.setattr(settings, "conventions_enabled", True)
agent, project = await _seed(db_session)
seeded = "Do the work\n\n## Constraints\n- a task-specific note"
task = await TaskService(db_session).create(_req(agent, project, seeded))
assert task.description is not None
assert "a task-specific note" in task.description
assert task.constraints is not None
assert "no lint suppressions" in task.constraints
async def test_baseline_attach_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
agent, project = await _seed(db_session)
svc = TaskService(db_session)
task = await svc.create(_req(agent, project, "Do the work"))
before = task.constraints
await svc._attach_baseline_constraints(task)
assert task.constraints == before
assert task.constraints is not None
assert task.constraints.count("no lint suppressions") == 1