feat(gateway): substantive-plan gate — approach >=150 + real sub_task descriptions (#171)

Plans were vague because the gate accepted the bare minimum: PM
approach >=20 chars and title-only sub_tasks. minimax wrote exactly
the minimum.

- IWillPlanRequest.approach min_length 20 -> 150 (kept in sync with
  _PM_APPROACH_MIN_LEN; the gate enforces it at the choreographer
  layer too so direct/MCP callers can't bypass the HTTP boundary).
- New _thin_subtask_hint: every PM sub_task must have a title and a
  description >= _PM_SUBTASK_DESC_MIN_LEN (60) saying what the step
  does — each sub_task is both a delegate target AND a
  progress-checklist item, so a title alone is not a plan.
- cell_pm/main_pm role prompts: explicit "the gate REJECTS thin plans"
  framing + concrete sub_task example + the new minimums.
- Updated all affected test fixtures across the suite to use
  substantive approaches/descriptions; added thin-sub_task rejection
  coverage.

Commit 1 of 3 for the plan/progress quality work (#171/#172/#173).
This commit is contained in:
Renn F
2026-05-16 10:14:45 +02:00
parent e94159dce8
commit ed828a719b
14 changed files with 415 additions and 82 deletions
+12 -3
View File
@@ -237,11 +237,20 @@ async def test_i_will_plan_dispatches_to_choreographer() -> None:
"task_id": _TASK_ID,
"plan": "break into 3 subtasks for backend",
"approach": (
"Decompose into backend API slice, QA verification pass, "
"and documentation update."
"Decompose into a backend API slice that be-dev-1 owns end "
"to end; QA reviews after the PR opens, documentation follows, "
"then be-pm completes and submits up. Single-cell — no "
"frontend or ux work; strict sequencing with no cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{"title": "Backend API slice", "description": "Implement endpoint"}
{
"title": "Backend API slice",
"description": (
"be-dev-1 implements the endpoint with tests, commits "
"with the task-id prefix, opens the leaf PR for QA."
),
}
],
},
headers=_HEADERS,
+19 -4
View File
@@ -208,12 +208,27 @@ async def test_i_will_plan_dispatches_to_choreographer() -> None:
"task_id": _TASK_ID,
"plan": "split into backend, frontend, ux cells",
"approach": (
"Three-cell decomposition: backend handles API, frontend "
"handles UI integration, ux-ui handles design."
"Three-cell decomposition: backend handles the API, frontend "
"handles UI integration, ux-ui handles design. Sequenced so "
"backend lands first, QA reviews each PR after it opens, "
"documentation follows, then complete and submit up. No "
"cross-cell dependencies beyond the stated ordering."
),
"sub_tasks": [
{"title": "Backend cell", "description": "API implementation"},
{"title": "Frontend cell", "description": "UI integration"},
{
"title": "Backend cell",
"description": (
"be-dev-1 implements the API endpoint and migration "
"with tests, opens the leaf PR for QA."
),
},
{
"title": "Frontend cell",
"description": (
"fe-dev-1 wires the panel to the new endpoint with "
"loading and error states, opens the leaf PR."
),
},
],
},
headers=_HEADERS,
@@ -19,7 +19,19 @@ _AGENT_ID = "00000000-0000-0000-0004-000000000001"
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "main_pm"}
_HTTP_UNPROCESSABLE = 422
_HTTP_OK = 200
_MIN_APPROACH_LEN = 20
# #171: raised 20→150 — must stay in sync with IWillPlanRequest.approach
# min_length and choreographer._impl._PM_APPROACH_MIN_LEN.
_MIN_APPROACH_LEN = 150
_GOOD_APPROACH = (
"Single-cell decomposition for the git-workflow smoke test: be-pm owns "
"the backend slice end to end — claim, branch, delegate the README edit "
"to be-dev-1, sequence QA after the PR opens, then documentation, then "
"complete and submit_up. Frontend and UX cells are unaffected."
)
_GOOD_SUBTASK_DESC = (
"be-dev-1 creates the feature branch, prepends the smoke-test HTML "
"comment above the README H1 leaving the rest untouched, commits, opens PR."
)
def _make_envelope(error: str, missing: list[str] | None = None) -> MagicMock:
@@ -76,10 +88,7 @@ def test_i_will_plan_rejects_empty_subtasks_for_pm() -> None:
json={
"task_id": str(uuid4()),
"plan": "Route to backend cell only",
"approach": (
"Single-cell decomposition: backend cell handles the "
"smoke test end-to-end; frontend and ux unaffected."
),
"approach": _GOOD_APPROACH,
"sub_tasks": [], # empty — gateway rejects for PM
},
)
@@ -101,11 +110,8 @@ def test_i_will_plan_schema_accepts_rich_plan() -> None:
req = IWillPlanRequest(
task_id=uuid4(),
plan="Route to backend",
approach=(
"Single-cell decomposition for the smoke test: be-pm handles "
"git workflow validation end to end."
),
sub_tasks=[{"title": "Backend slice", "description": "Branch + edit + PR"}],
approach=_GOOD_APPROACH,
sub_tasks=[{"title": "Backend slice", "description": _GOOD_SUBTASK_DESC}],
risks=[],
open_questions=[],
)
@@ -397,11 +397,20 @@ async def test_cell_pm_can_plan_code_typed_parent_via_i_will_plan() -> None:
plan="Decompose into 2 dev subtasks.",
rich_plan={
"approach": (
"Split code-typed parent into two developer-claimable subtasks: "
"one for API implementation, one for test coverage."
"Split the code-typed parent into two developer-claimable "
"subtasks: one for API implementation, one for test coverage. "
"Sequenced so the API lands first; QA reviews each PR after "
"it opens, documentation follows, then complete and submit "
"up. No cross-cell dependencies for this slice."
),
"sub_tasks": [
{"title": "API subtask", "description": "Implement endpoint"},
{
"title": "API subtask",
"description": (
"be-dev-1 implements the endpoint with tests, commits "
"with the task-id prefix, opens the leaf PR for QA."
),
},
],
},
)
@@ -450,10 +459,19 @@ async def test_pm_can_plan_non_code_parent() -> None:
rich_plan={
"approach": (
"Single-cell decomposition: backend handles the full scope; "
"no frontend or ux work required for this planning task."
"no frontend or ux work required for this planning task. "
"be-dev-1 owns the change end to end; QA reviews after the "
"PR opens, documentation follows, then be-pm completes and "
"submits up. Strict sequencing, no cross-cell dependencies."
),
"sub_tasks": [
{"title": "Backend planning slice", "description": "Scope and assign"}
{
"title": "Backend planning slice",
"description": (
"scope the change, assign be-dev-1, who implements "
"with tests and opens the leaf PR for QA review."
),
}
],
},
)
@@ -325,8 +325,22 @@ async def test_i_will_plan_pm_with_already_active_task_rejects() -> None:
task_id,
plan="x" * 30,
rich_plan={
"approach": "Decompose planning task into backend and frontend subtasks.",
"sub_tasks": [{"title": "Slice A", "description": "backend API work"}],
"approach": (
"Decompose the planning task into backend and frontend "
"developer-claimable subtasks. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
@@ -427,8 +441,22 @@ async def test_i_will_plan_pending_claim_fails() -> None:
task_id,
plan="my plan that is long enough",
rich_plan={
"approach": "Decompose planning task into backend and frontend subtasks.",
"sub_tasks": [{"title": "Slice A", "description": "backend API work"}],
"approach": (
"Decompose the planning task into backend and frontend "
"developer-claimable subtasks. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
@@ -1047,8 +1075,22 @@ async def test_i_will_plan_pending_claim_returns_none_emit_rejection() -> None:
task_id,
plan="my plan that is long enough",
rich_plan={
"approach": "Decompose planning task into backend and frontend subtasks.",
"sub_tasks": [{"title": "Slice A", "description": "backend API work"}],
"approach": (
"Decompose the planning task into backend and frontend "
"developer-claimable subtasks. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
@@ -118,11 +118,26 @@ async def test_i_will_plan_claims_starts_and_sets_plan() -> None:
rich_plan={
"approach": (
"Three-cell decomposition: backend, frontend, and ux each "
"own a vertical slice of the work."
"own a vertical slice of the work. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{"title": "Backend slice", "description": "API + DB"},
{"title": "Frontend slice", "description": "UI integration"},
{
"title": "Backend slice",
"description": (
"be-dev-1 implements the API + DB migration with "
"tests and opens the leaf PR for QA review."
),
},
{
"title": "Frontend slice",
"description": (
"fe-dev-1 wires the UI integration with loading and "
"error states and opens the leaf PR for QA."
),
},
],
},
)
@@ -189,11 +204,26 @@ async def test_i_will_plan_blocks_when_journal_decision_at_claim_missing() -> No
rich_plan={
"approach": (
"Three-cell decomposition: backend, frontend, and ux each "
"own a vertical slice of the work."
"own a vertical slice of the work. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{"title": "Backend slice", "description": "API + DB"},
{"title": "Frontend slice", "description": "UI integration"},
{
"title": "Backend slice",
"description": (
"be-dev-1 implements the API + DB migration with "
"tests and opens the leaf PR for QA review."
),
},
{
"title": "Frontend slice",
"description": (
"fe-dev-1 wires the UI integration with loading and "
"error states and opens the leaf PR for QA."
),
},
],
},
)
@@ -239,8 +269,22 @@ async def test_i_will_plan_rejects_non_pending_state() -> None:
task_id,
plan="x",
rich_plan={
"approach": "Single-cell decomposition: backend handles all scope.",
"sub_tasks": [{"title": "Slice A", "description": "backend API work"}],
"approach": (
"Single-cell decomposition: backend handles all scope. "
"be-dev-1 owns the change end to end — branch, implement, "
"test, open PR; QA reviews after the PR opens, documentation "
"follows, then be-pm completes and submits up. No cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
@@ -300,12 +344,27 @@ async def test_i_will_plan_calls_claim_when_pre_assigned_and_pending() -> None:
plan="distribute to be-pm and fe-pm",
rich_plan={
"approach": (
"Two-cell dispatch: be-pm owns backend vertical, "
"fe-pm owns frontend vertical."
"Two-cell dispatch: be-pm owns the backend vertical, fe-pm "
"owns the frontend vertical. Backend lands first, QA reviews "
"each PR after it opens, documentation follows, then complete "
"and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{"title": "Backend cell", "description": "Assign to be-pm"},
{"title": "Frontend cell", "description": "Assign to fe-pm"},
{
"title": "Backend cell",
"description": (
"be-pm decomposes and delegates the backend vertical "
"to be-dev-1, who implements and opens the leaf PR."
),
},
{
"title": "Frontend cell",
"description": (
"fe-pm decomposes and delegates the frontend vertical "
"to fe-dev-1, who implements and opens the leaf PR."
),
},
],
},
)
@@ -363,8 +422,22 @@ async def test_i_will_plan_surfaces_start_failure_instead_of_faking_ok() -> None
task_id,
plan="x",
rich_plan={
"approach": "Single-cell decomposition: backend handles all scope.",
"sub_tasks": [{"title": "Slice A", "description": "backend API work"}],
"approach": (
"Single-cell decomposition: backend handles all scope. "
"be-dev-1 owns the change end to end — branch, implement, "
"test, open PR; QA reviews after the PR opens, documentation "
"follows, then be-pm completes and submits up. No cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
@@ -508,8 +581,22 @@ async def test_i_will_plan_still_rejects_in_progress_for_other_agent() -> None:
task_id,
plan="x",
rich_plan={
"approach": "Single-cell decomposition: backend handles all scope.",
"sub_tasks": [{"title": "Slice A", "description": "backend API work"}],
"approach": (
"Single-cell decomposition: backend handles all scope. "
"be-dev-1 owns the change end to end — branch, implement, "
"test, open PR; QA reviews after the PR opens, documentation "
"follows, then be-pm completes and submits up. No cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
@@ -534,8 +621,22 @@ async def test_i_will_plan_returns_tracing_gap_without_plan() -> None:
task_id,
plan="",
rich_plan={
"approach": "Single-cell decomposition: backend handles all scope.",
"sub_tasks": [{"title": "Slice A", "description": "backend API work"}],
"approach": (
"Single-cell decomposition: backend handles all scope. "
"be-dev-1 owns the change end to end — branch, implement, "
"test, open PR; QA reviews after the PR opens, documentation "
"follows, then be-pm completes and submits up. No cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
+18 -3
View File
@@ -275,11 +275,26 @@ async def test_i_will_plan_calls_claim_and_start_with_task_id_first() -> None:
rich_plan={
"approach": (
"Three-cell decomposition: backend, frontend, and ux each "
"own a vertical slice of the work."
"own a vertical slice of the work. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{"title": "Backend slice", "description": "API + DB"},
{"title": "Frontend slice", "description": "UI integration"},
{
"title": "Backend slice",
"description": (
"be-dev-1 implements the API + DB migration with "
"tests and opens the leaf PR for QA review."
),
},
{
"title": "Frontend slice",
"description": (
"fe-dev-1 wires the UI integration with loading and "
"error states and opens the leaf PR for QA."
),
},
],
},
)
@@ -133,11 +133,28 @@ async def test_i_will_plan_persists_panel_shaped_plan() -> None:
c = Choreographer(deps)
sub_tasks_in = [
{"title": "Backend slice", "description": "API + DB schema"},
{"title": "UX slice", "description": "Panel changes"},
{
"title": "Backend slice",
"description": (
"be-dev-1 implements the API endpoint and the DB schema "
"migration, with tests, behind the existing service layer."
),
},
{
"title": "UX slice",
"description": (
"fe-dev-1 wires the panel view to the new endpoint and "
"renders the result list with loading + error states."
),
},
]
rich = {
"approach": "Three-slice decomposition: api, db, ui — each as a subtask.",
"approach": (
"Three-slice decomposition for the feature: backend builds the "
"API + DB migration first, UX consumes it after the endpoint is "
"merged, QA reviews each PR, docs follow. Strict sequencing; the "
"UX slice depends on the backend slice landing first."
),
"sub_tasks": sub_tasks_in,
"risks": [{"risk": "schema migration may block", "mitigation": "rehearse"}],
}
@@ -170,12 +187,11 @@ async def test_i_will_plan_persists_panel_shaped_plan() -> None:
@pytest.mark.asyncio
async def test_i_will_plan_with_thin_rich_plan_persists_string() -> None:
"""PM passing rich_plan with empty rich fields — set_plan receives the str.
"""PM passing a valid rich_plan — set_plan receives the dict shape.
_resolve_effective_plan only switches to the dict shape when at least one
rich-plan field is populated. If a PM somehow bypasses _pm_sub_tasks_gate
(e.g. via the re-entry path) with an empty rich_plan, the raw string
must still pass through. TaskService.set_plan wraps it as {"text": str}.
Regression coverage for _resolve_effective_plan: with rich-plan fields
populated the dict shape (not the raw string) must reach set_plan, and
the raw paragraph is preserved under `text`.
"""
pm_id = uuid4()
task_id = uuid4()
@@ -183,15 +199,28 @@ async def test_i_will_plan_with_thin_rich_plan_persists_string() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
# Single sub_task satisfies the gate; we test the str-vs-dict branch of
# _resolve_effective_plan by checking what shape reaches set_plan.
# A valid rich_plan satisfies the gate; we test the str-vs-dict branch
# of _resolve_effective_plan by checking what shape reaches set_plan.
env = await c.i_will_plan(
pm_id,
task_id,
plan="bare plan paragraph",
rich_plan={
"approach": "PM-approved approach text that is long enough to pass.",
"sub_tasks": [{"title": "Slice", "description": "the work"}],
"approach": (
"Single-slice decomposition: be-dev-1 owns the change end "
"to end — branch, implement, test, open PR; QA reviews after "
"the PR opens; docs follow; then be-pm completes and submits "
"up. No cross-cell dependencies for this task."
),
"sub_tasks": [
{
"title": "Slice",
"description": (
"be-dev-1 implements the change with tests and opens "
"the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
@@ -20,7 +20,18 @@ from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
_MIN_APPROACH_LEN = 20
_MIN_APPROACH_LEN = 150 # #171: raised 20→150 (must match _PM_APPROACH_MIN_LEN)
_GOOD_APPROACH = (
"Three-cell decomposition for the smoke test: backend owns the README "
"edit + PR, QA reviews after the PR opens, documentation follows, then "
"be-pm completes and submits up. Sequenced strictly; no cross-cell deps. "
"Frontend and UX cells are explicitly out of scope for this task."
)
_GOOD_SUBTASK_DESC = (
"be-dev-1 branches, prepends the smoke-test HTML comment above the "
"README H1 leaving the rest untouched, commits with the task-id prefix, "
"and opens the leaf PR for QA."
)
# ---------------------------------------------------------------------------
@@ -203,8 +214,10 @@ async def test_pm_with_filled_sub_tasks_passes_gate() -> None:
task_id,
plan="decompose work",
rich_plan={
"approach": "Three-cell decomposition: backend, frontend, ux.",
"sub_tasks": [{"title": "Backend slice", "description": "API + DB"}],
"approach": _GOOD_APPROACH,
"sub_tasks": [
{"title": "Backend slice", "description": _GOOD_SUBTASK_DESC}
],
},
)
body = env.as_dict()
@@ -213,6 +226,34 @@ async def test_pm_with_filled_sub_tasks_passes_gate() -> None:
assert body.get("error") != "incomplete_input", body
@pytest.mark.asyncio
async def test_pm_with_thin_subtask_description_gets_incomplete_input() -> None:
"""#171: a good approach but a title-only / thin sub_task description is
rejected sub_tasks must be real steps (delegate target + progress item)."""
pm_id = uuid4()
task_id = uuid4()
task_svc = _pm_task_svc(task_id, role="cell_pm")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="decompose work",
rich_plan={
"approach": _GOOD_APPROACH,
"sub_tasks": [{"title": "Backend slice", "description": "API + DB"}],
},
)
body = env.as_dict()
assert body["error"] == "incomplete_input", body
assert "sub_tasks" in (body.get("missing") or []), body
assert (
"thin" in str(body.get("field_hints", {})).lower()
or "thin" in str(body.get("remediate", "")).lower()
), body
# ---------------------------------------------------------------------------
# Test 4: Developer with empty sub_tasks → gate does NOT fire
# ---------------------------------------------------------------------------
@@ -218,8 +218,21 @@ async def test_i_will_plan_calls_ensure_work_session() -> None:
c = Choreographer(deps)
rich_plan = {
"approach": "plan text with enough detail to pass the 20 char gate",
"sub_tasks": [{"title": "t1", "description": "desc1"}],
"approach": (
"Single-cell decomposition: be-dev-1 owns the change end to end "
"— branch, implement, test, open PR; QA reviews after the PR "
"opens, documentation follows, then be-pm completes and submits "
"up. No cross-cell dependencies for this planning task."
),
"sub_tasks": [
{
"title": "t1",
"description": (
"be-dev-1 implements the change with tests and opens the "
"leaf PR for QA review."
),
}
],
}
env = await c.i_will_plan(
pm_agent_id, task_id, plan="plan text", rich_plan=rich_plan