[chore] logical-gaps: kanban board column coverage + status-class fixes (6 gaps)

models/kanban.py:
- DEV_COLUMNS: cover all 15 lifecycle statuses (was 7; dropped BACKLOG,
  PAUSED, VERIFYING, NEEDS_REVISION, AWAITING_PR_REVIEW, AWAITING_PM_REVIEW,
  AWAITING_CEO_APPROVAL, CANCELLED). A dev whose task bounced to
  needs_revision or sits in a gate used to see their own task vanish.
- PM_COLUMNS: add the gate/revision/paused/cancelled/backlog columns so the
  cell PM sees the QA->docs->PR-review->PM-review->CEO chain on its board.
- QA_COLUMNS: drop the 'In Review'->VERIFYING mapping. VERIFYING is the dev's
  self-verification (task still with the dev, not with QA); it misrepresented
  dev mid-verification as active QA work.

services/kanban.py:
- _build_flat_board: add an 'Other' fallback column for any task whose status
  matches no configured column, so total_cards == sum(card_count) and no card
  is built-then-silently-dropped (the vanished-card leak).
- get_qa_board: drop VERIFYING from qa_statuses (consistent with the column
  change).
- get_documenter_board: scope to task_type=documentation so a dev IN_PROGRESS
  code task sharing the cell team no longer appears under 'Gathering'.
- get_main_pm_board_flat: widen the status filter to include PENDING/CLAIMED/
  COMPLETED and route those to the incoming/distributed/done columns, which
  were structurally always empty under the in-flight-only filter.

tests/integration/test_kanban_service.py: parametrized coverage of every
dropped dev status, PM gate/revision states, QA excludes VERIFYING,
documenter excludes dev code tasks, flat Main PM incoming/distributed/done
populated, and the 'Other' fallback invariant.
This commit is contained in:
Renn F
2026-06-30 12:55:05 +02:00
parent b49337e7fd
commit c71f9b3b01
3 changed files with 242 additions and 12 deletions
+25 -2
View File
@@ -100,18 +100,28 @@ class KanbanBoard(RobocoBase):
DEV_COLUMNS = [ DEV_COLUMNS = [
("backlog", "Backlog", TaskStatus.PENDING), ("backlog", "Backlog", TaskStatus.BACKLOG),
("pending", "Ready", TaskStatus.PENDING),
("assigned", "Assigned", TaskStatus.CLAIMED), ("assigned", "Assigned", TaskStatus.CLAIMED),
("in_progress", "In Progress", TaskStatus.IN_PROGRESS), ("in_progress", "In Progress", TaskStatus.IN_PROGRESS),
("verifying", "Verifying", TaskStatus.VERIFYING),
("blocked", "Blocked", TaskStatus.BLOCKED), ("blocked", "Blocked", TaskStatus.BLOCKED),
("paused", "Paused", TaskStatus.PAUSED),
("needs_revision", "Needs Revision", TaskStatus.NEEDS_REVISION),
("qa_review", "QA Review", TaskStatus.AWAITING_QA), ("qa_review", "QA Review", TaskStatus.AWAITING_QA),
("documenting", "Documenting", TaskStatus.AWAITING_DOCUMENTATION), ("documenting", "Documenting", TaskStatus.AWAITING_DOCUMENTATION),
("pr_review", "PR Review", TaskStatus.AWAITING_PR_REVIEW),
("pm_review", "PM Review", TaskStatus.AWAITING_PM_REVIEW),
("ceo_approval", "CEO Approval", TaskStatus.AWAITING_CEO_APPROVAL),
("done", "Done", TaskStatus.COMPLETED), ("done", "Done", TaskStatus.COMPLETED),
("cancelled", "Cancelled", TaskStatus.CANCELLED),
] ]
# VERIFYING is the developer's self-verification state — the task is still with
# the dev, not with QA. Mapping it to an 'In Review' column misrepresented dev
# mid-verification as active QA work; QA reviews tasks that reached AWAITING_QA.
QA_COLUMNS = [ QA_COLUMNS = [
("awaiting_review", "Awaiting Review", TaskStatus.AWAITING_QA), ("awaiting_review", "Awaiting Review", TaskStatus.AWAITING_QA),
("in_review", "In Review", TaskStatus.VERIFYING),
("passed", "Passed", TaskStatus.AWAITING_DOCUMENTATION), ("passed", "Passed", TaskStatus.AWAITING_DOCUMENTATION),
("failed", "Failed", TaskStatus.NEEDS_REVISION), ("failed", "Failed", TaskStatus.NEEDS_REVISION),
] ]
@@ -123,12 +133,25 @@ DOCUMENTER_COLUMNS = [
("published", "Published", TaskStatus.COMPLETED), ("published", "Published", TaskStatus.COMPLETED),
] ]
# The cell PM coordinates the QA -> docs -> PR-review -> PM-review -> CEO chain,
# so every in-flight gate/revision/paused/cancelled status must be visible, not
# dropped by a column set that only knew pending/claimed/in_progress/blocked.
PM_COLUMNS = [ PM_COLUMNS = [
("incoming", "Incoming", TaskStatus.PENDING), ("incoming", "Incoming", TaskStatus.PENDING),
("triaged", "Triaged", TaskStatus.CLAIMED), ("triaged", "Triaged", TaskStatus.CLAIMED),
("assigned", "Assigned", TaskStatus.IN_PROGRESS), ("assigned", "Assigned", TaskStatus.IN_PROGRESS),
("verifying", "Verifying", TaskStatus.VERIFYING),
("blocked", "Blocked", TaskStatus.BLOCKED), ("blocked", "Blocked", TaskStatus.BLOCKED),
("paused", "Paused", TaskStatus.PAUSED),
("needs_revision", "Needs Revision", TaskStatus.NEEDS_REVISION),
("qa_review", "QA Review", TaskStatus.AWAITING_QA),
("documenting", "Documenting", TaskStatus.AWAITING_DOCUMENTATION),
("pr_review", "PR Review", TaskStatus.AWAITING_PR_REVIEW),
("pm_review", "PM Review", TaskStatus.AWAITING_PM_REVIEW),
("ceo_approval", "CEO Approval", TaskStatus.AWAITING_CEO_APPROVAL),
("done", "Done", TaskStatus.COMPLETED), ("done", "Done", TaskStatus.COMPLETED),
("cancelled", "Cancelled", TaskStatus.CANCELLED),
("backlog", "Backlog", TaskStatus.BACKLOG),
] ]
MAIN_PM_COLUMNS = [ MAIN_PM_COLUMNS = [
+46 -9
View File
@@ -14,7 +14,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import AgentTable, TaskTable from roboco.db.tables import AgentTable, TaskTable
from roboco.models.base import TaskStatus, Team from roboco.models.base import TaskStatus, TaskType, Team
from roboco.models.kanban import ( from roboco.models.kanban import (
KanbanBoard, KanbanBoard,
KanbanBoardType, KanbanBoardType,
@@ -161,28 +161,47 @@ class KanbanService(BaseService):
card_count=0, card_count=0,
) )
# Add cards to columns # Add cards to columns; any task whose status matches no configured
# column lands in an 'Other' fallback so total_cards == sum(card_count)
# and no card is built-then-silently-dropped (the vanished-card leak).
other_cards: list[KanbanCard] = []
blocked_count = 0 blocked_count = 0
subtask_counts = await self._load_subtask_counts(tasks) subtask_counts = await self._load_subtask_counts(tasks)
for task in tasks: for task in tasks:
card = await self._task_to_card(task, subtask_counts=subtask_counts) card = await self._task_to_card(task, subtask_counts=subtask_counts)
# Find the right column for this task's status # Find the right column for this task's status
placed = False
for col_id, _, col_status in column_config: for col_id, _, col_status in column_config:
if task.status == col_status: if task.status == col_status:
columns[col_id].cards.append(card) columns[col_id].cards.append(card)
columns[col_id].card_count += 1 columns[col_id].card_count += 1
placed = True
break break
if not placed:
other_cards.append(card)
if task.status == TaskStatus.BLOCKED: if task.status == TaskStatus.BLOCKED:
blocked_count += 1 blocked_count += 1
column_list = list(columns.values())
if other_cards:
column_list.append(
KanbanColumn(
id="other",
title="Other",
status=None,
cards=other_cards,
card_count=len(other_cards),
)
)
return KanbanBoard( return KanbanBoard(
id=f"{board_type.value}-{team.value if team else 'all'}", id=f"{board_type.value}-{team.value if team else 'all'}",
title=f"{team.value.title() if team else 'All'} {board_type.value.title()} Board", # noqa: E501 title=f"{team.value.title() if team else 'All'} {board_type.value.title()} Board", # noqa: E501
board_type=board_type, board_type=board_type,
team=team, team=team,
columns=list(columns.values()), columns=column_list,
total_cards=len(tasks), total_cards=len(tasks),
blocked_count=blocked_count, blocked_count=blocked_count,
last_updated=datetime.now(UTC), last_updated=datetime.now(UTC),
@@ -319,10 +338,11 @@ class KanbanService(BaseService):
Columns: Awaiting Review → In Review → Passed → Failed Columns: Awaiting Review → In Review → Passed → Failed
""" """
# QA only sees tasks in QA-relevant statuses # QA only sees tasks in QA-relevant statuses. VERIFYING is the dev's
# self-verification (task still with the dev, not with QA) — excluded so
# the QA board does not show dev-mid-verification as active QA work.
qa_statuses = [ qa_statuses = [
TaskStatus.AWAITING_QA, TaskStatus.AWAITING_QA,
TaskStatus.VERIFYING,
TaskStatus.AWAITING_DOCUMENTATION, TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.NEEDS_REVISION, TaskStatus.NEEDS_REVISION,
] ]
@@ -349,7 +369,10 @@ class KanbanService(BaseService):
Columns: Awaiting Handoff → Gathering → Writing → Published Columns: Awaiting Handoff → Gathering → Writing → Published
""" """
# Documenter sees tasks awaiting documentation or completed # Documenter sees documentation-typed tasks in doc-relevant statuses.
# The broad IN_PROGRESS/VERIFYING inclusion used to load any dev code
# task happening to share the cell team; scope to task_type=documentation
# so the board shows only the documenter's own pipeline.
doc_statuses = [ doc_statuses = [
TaskStatus.AWAITING_DOCUMENTATION, TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.IN_PROGRESS, # Gathering TaskStatus.IN_PROGRESS, # Gathering
@@ -362,6 +385,7 @@ class KanbanService(BaseService):
.where( .where(
TaskTable.team == team, TaskTable.team == team,
TaskTable.status.in_(doc_statuses), TaskTable.status.in_(doc_statuses),
TaskTable.task_type == TaskType.DOCUMENTATION,
) )
.order_by(TaskTable.priority, TaskTable.created_at.desc()) .order_by(TaskTable.priority, TaskTable.created_at.desc())
) )
@@ -410,15 +434,21 @@ class KanbanService(BaseService):
async def get_main_pm_board_flat(self) -> KanbanBoard: async def get_main_pm_board_flat(self) -> KanbanBoard:
"""Get Main PM board with team columns instead of swimlanes.""" """Get Main PM board with team columns instead of swimlanes."""
# Get tasks grouped by team # Load every status the board columns represent. The legacy filter
# (in_progress/blocked/awaiting_qa only) excluded exactly the statuses
# the incoming/distributed/done columns anchor, so those three columns
# were structurally always empty.
result = await self.session.execute( result = await self.session.execute(
select(TaskTable) select(TaskTable)
.where( .where(
TaskTable.status.in_( TaskTable.status.in_(
[ [
TaskStatus.PENDING,
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS, TaskStatus.IN_PROGRESS,
TaskStatus.BLOCKED, TaskStatus.BLOCKED,
TaskStatus.AWAITING_QA, TaskStatus.AWAITING_QA,
TaskStatus.COMPLETED,
] ]
) )
) )
@@ -457,14 +487,21 @@ class KanbanService(BaseService):
), ),
] ]
# Sort tasks into columns # Sort tasks into columns: status-keyed columns (incoming/distributed/
# done) first, then the in-flight statuses route by team.
col_map = {col.id: col for col in columns} col_map = {col.id: col for col in columns}
blocked_count = 0 blocked_count = 0
subtask_counts = await self._load_subtask_counts(tasks) subtask_counts = await self._load_subtask_counts(tasks)
for task in tasks: for task in tasks:
card = await self._task_to_card(task, subtask_counts=subtask_counts) card = await self._task_to_card(task, subtask_counts=subtask_counts)
if task.team == Team.BACKEND: if task.status == TaskStatus.PENDING:
col_map["incoming"].cards.append(card)
elif task.status == TaskStatus.CLAIMED:
col_map["distributed"].cards.append(card)
elif task.status == TaskStatus.COMPLETED:
col_map["done"].cards.append(card)
elif task.team == Team.BACKEND:
col_map["backend"].cards.append(card) col_map["backend"].cards.append(card)
elif task.team == Team.FRONTEND: elif task.team == Team.FRONTEND:
col_map["frontend"].cards.append(card) col_map["frontend"].cards.append(card)
+171 -1
View File
@@ -67,7 +67,7 @@ def _seed(setup: dict, *, status: TaskStatus, **kw: Any) -> TaskTable:
acceptance_criteria=["ac"], acceptance_criteria=["ac"],
status=status, status=status,
priority=kw.pop("priority", 2), priority=kw.pop("priority", 2),
task_type=TaskType.CODE, task_type=kw.pop("task_type", TaskType.CODE),
nature=TaskNature.TECHNICAL, nature=TaskNature.TECHNICAL,
project_id=setup["project_id"], project_id=setup["project_id"],
created_by=setup["agent_id"], created_by=setup["agent_id"],
@@ -425,3 +425,173 @@ async def test_priority_swimlane_board_reports_real_subtask_count(
card = _find_card(board, parent.id) card = _find_card(board, parent.id)
assert card.subtask_count == 1 assert card.subtask_count == 1
assert card.has_subtasks is True assert card.has_subtasks is True
# ---------------------------------------------------------------------------
# Column coverage: no task status may vanish from a board (total_cards == sum)
# ---------------------------------------------------------------------------
# The 8 statuses the legacy DEV_COLUMNS dropped: BACKLOG, PAUSED, VERIFYING,
# NEEDS_REVISION, AWAITING_PR_REVIEW, AWAITING_PM_REVIEW, AWAITING_CEO_APPROVAL,
# CANCELLED. A dev whose task bounced to needs_revision (or sits in a gate) used
# to see their own task disappear from the board.
_DROPPED_DEV_STATUSES = [
TaskStatus.BACKLOG,
TaskStatus.PAUSED,
TaskStatus.VERIFYING,
TaskStatus.NEEDS_REVISION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
TaskStatus.CANCELLED,
]
@pytest.mark.asyncio
@pytest.mark.parametrize("status", _DROPPED_DEV_STATUSES)
async def test_dev_board_shows_every_status(
kanban_setup: dict, status: TaskStatus
) -> None:
"""A dev task in any lifecycle status must appear in exactly one column —
it must not be silently dropped (the card-counted-but-hidden leak)."""
db = kanban_setup["db"]
svc = kanban_setup["svc"]
db.add(_seed(kanban_setup, status=status))
await db.flush()
board = await svc.get_dev_board(Team.BACKEND)
placed = sum(len(c.cards) for c in board.columns)
assert placed == board.total_cards
assert any(c.cards for c in board.columns)
@pytest.mark.asyncio
async def test_dev_board_total_cards_equals_column_sum(kanban_setup: dict) -> None:
"""The board must never report N total cards while only M are visible."""
db = kanban_setup["db"]
svc = kanban_setup["svc"]
for status in (
TaskStatus.IN_PROGRESS,
TaskStatus.NEEDS_REVISION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.PAUSED,
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
):
db.add(_seed(kanban_setup, status=status))
await db.flush()
board = await svc.get_dev_board(Team.BACKEND)
assert sum(c.card_count for c in board.columns) == board.total_cards
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status",
[
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
TaskStatus.NEEDS_REVISION,
TaskStatus.PAUSED,
TaskStatus.CANCELLED,
],
)
async def test_pm_board_shows_gate_and_revision_states(
kanban_setup: dict, status: TaskStatus
) -> None:
"""The cell PM coordinates the QA->docs->PR-review->PM-review->CEO chain, so
every in-flight gate/revision/paused/cancelled status must be visible — not
dropped by a column mapping that only knows pending/claimed/in_progress/
blocked/done."""
db = kanban_setup["db"]
svc = kanban_setup["svc"]
db.add(_seed(kanban_setup, status=status))
await db.flush()
board = await svc.get_pm_board(Team.BACKEND)
assert sum(c.card_count for c in board.columns) == board.total_cards
assert any(c.cards for c in board.columns)
@pytest.mark.asyncio
async def test_qa_board_excludes_dev_verifying(kanban_setup: dict) -> None:
"""VERIFYING is the developer's self-verification state — the task is still
with the dev, not with QA. The QA board's 'In Review' column used to show
these dev-mid-verification tasks as if QA work were underway."""
db = kanban_setup["db"]
svc = kanban_setup["svc"]
verifying = _seed(kanban_setup, status=TaskStatus.VERIFYING, title="dev-self-check")
queued = _seed(kanban_setup, status=TaskStatus.AWAITING_QA, title="qa-queue")
db.add_all([verifying, queued])
await db.flush()
board = await svc.get_qa_board(Team.BACKEND)
cards = [c for col in board.columns for c in col.cards]
ids = {c.id for c in cards}
assert queued.id in ids
assert verifying.id not in ids
@pytest.mark.asyncio
async def test_documenter_board_excludes_dev_code_tasks(kanban_setup: dict) -> None:
"""The documenter shares a cell team with devs, so a dev IN_PROGRESS code
task used to appear under 'Gathering' as if it were documentation. The
documenter board must be scoped to task_type=documentation."""
db = kanban_setup["db"]
svc = kanban_setup["svc"]
dev_task = _seed(
kanban_setup,
status=TaskStatus.IN_PROGRESS,
title="dev-code",
task_type=TaskType.CODE,
)
doc_task = _seed(
kanban_setup,
status=TaskStatus.IN_PROGRESS,
title="doc-task",
task_type=TaskType.DOCUMENTATION,
)
db.add_all([dev_task, doc_task])
await db.flush()
board = await svc.get_documenter_board(Team.BACKEND)
cards = [c for col in board.columns for c in col.cards]
ids = {c.id for c in cards}
assert doc_task.id in ids
assert dev_task.id not in ids
@pytest.mark.asyncio
async def test_main_pm_board_flat_incoming_distributed_done_populated(
kanban_setup: dict,
) -> None:
"""The flat Main PM board filtered to in-flight-only, so its own
incoming(PENDING)/distributed(CLAIMED)/done(COMPLETED) columns were
structurally always empty. Those columns must now show matching tasks."""
db = kanban_setup["db"]
svc = kanban_setup["svc"]
db.add(_seed(kanban_setup, status=TaskStatus.PENDING, team=Team.BACKEND))
db.add(_seed(kanban_setup, status=TaskStatus.CLAIMED, team=Team.FRONTEND))
db.add(_seed(kanban_setup, status=TaskStatus.COMPLETED, team=Team.BACKEND))
db.add(_seed(kanban_setup, status=TaskStatus.IN_PROGRESS, team=Team.BACKEND))
await db.flush()
board = await svc.get_main_pm_board_flat()
cols = {c.id: c for c in board.columns}
assert len(cols["incoming"].cards) >= 1
assert len(cols["distributed"].cards) >= 1
assert len(cols["done"].cards) >= 1
# No loaded task vanishes: the columned cards cover every loaded task.
assert sum(len(c.cards) for c in board.columns) == board.total_cards
@pytest.mark.asyncio
async def test_build_flat_board_has_other_fallback(kanban_setup: dict) -> None:
"""A status with no configured column lands in an 'Other' fallback column so
no card is ever silently dropped (the total_cards > sum(card_count) leak)."""
db = kanban_setup["db"]
svc = kanban_setup["svc"]
# The Board roadmap (BOARD_COLUMNS) maps only pending/claimed/in_progress/
# completed; an awaiting_qa task at P0 loads but matches no column.
db.add(_seed(kanban_setup, status=TaskStatus.AWAITING_QA, priority=0))
await db.flush()
board = await svc.get_board_kanban()
assert any(c.id == "other" and c.card_count == 1 for c in board.columns)
assert sum(c.card_count for c in board.columns) == board.total_cards