From c71f9b3b01bdbea2772729f9da5cd98838055b26 Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 30 Jun 2026 12:55:05 +0200 Subject: [PATCH] [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. --- roboco/models/kanban.py | 27 +++- roboco/services/kanban.py | 55 ++++++-- tests/integration/test_kanban_service.py | 172 ++++++++++++++++++++++- 3 files changed, 242 insertions(+), 12 deletions(-) diff --git a/roboco/models/kanban.py b/roboco/models/kanban.py index de6239e0..039ae925 100644 --- a/roboco/models/kanban.py +++ b/roboco/models/kanban.py @@ -100,18 +100,28 @@ class KanbanBoard(RobocoBase): DEV_COLUMNS = [ - ("backlog", "Backlog", TaskStatus.PENDING), + ("backlog", "Backlog", TaskStatus.BACKLOG), + ("pending", "Ready", TaskStatus.PENDING), ("assigned", "Assigned", TaskStatus.CLAIMED), ("in_progress", "In Progress", TaskStatus.IN_PROGRESS), + ("verifying", "Verifying", TaskStatus.VERIFYING), ("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), + ("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 = [ ("awaiting_review", "Awaiting Review", TaskStatus.AWAITING_QA), - ("in_review", "In Review", TaskStatus.VERIFYING), ("passed", "Passed", TaskStatus.AWAITING_DOCUMENTATION), ("failed", "Failed", TaskStatus.NEEDS_REVISION), ] @@ -123,12 +133,25 @@ DOCUMENTER_COLUMNS = [ ("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 = [ ("incoming", "Incoming", TaskStatus.PENDING), ("triaged", "Triaged", TaskStatus.CLAIMED), ("assigned", "Assigned", TaskStatus.IN_PROGRESS), + ("verifying", "Verifying", TaskStatus.VERIFYING), ("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), + ("cancelled", "Cancelled", TaskStatus.CANCELLED), + ("backlog", "Backlog", TaskStatus.BACKLOG), ] MAIN_PM_COLUMNS = [ diff --git a/roboco/services/kanban.py b/roboco/services/kanban.py index 5437d8b5..3ffacbeb 100644 --- a/roboco/services/kanban.py +++ b/roboco/services/kanban.py @@ -14,7 +14,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession 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 ( KanbanBoard, KanbanBoardType, @@ -161,28 +161,47 @@ class KanbanService(BaseService): 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 subtask_counts = await self._load_subtask_counts(tasks) for task in tasks: card = await self._task_to_card(task, subtask_counts=subtask_counts) # Find the right column for this task's status + placed = False for col_id, _, col_status in column_config: if task.status == col_status: columns[col_id].cards.append(card) columns[col_id].card_count += 1 + placed = True break + if not placed: + other_cards.append(card) if task.status == TaskStatus.BLOCKED: 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( 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 board_type=board_type, team=team, - columns=list(columns.values()), + columns=column_list, total_cards=len(tasks), blocked_count=blocked_count, last_updated=datetime.now(UTC), @@ -319,10 +338,11 @@ class KanbanService(BaseService): 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 = [ TaskStatus.AWAITING_QA, - TaskStatus.VERIFYING, TaskStatus.AWAITING_DOCUMENTATION, TaskStatus.NEEDS_REVISION, ] @@ -349,7 +369,10 @@ class KanbanService(BaseService): 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 = [ TaskStatus.AWAITING_DOCUMENTATION, TaskStatus.IN_PROGRESS, # Gathering @@ -362,6 +385,7 @@ class KanbanService(BaseService): .where( TaskTable.team == team, TaskTable.status.in_(doc_statuses), + TaskTable.task_type == TaskType.DOCUMENTATION, ) .order_by(TaskTable.priority, TaskTable.created_at.desc()) ) @@ -410,15 +434,21 @@ class KanbanService(BaseService): async def get_main_pm_board_flat(self) -> KanbanBoard: """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( select(TaskTable) .where( TaskTable.status.in_( [ + TaskStatus.PENDING, + TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED, 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} blocked_count = 0 subtask_counts = await self._load_subtask_counts(tasks) for task in tasks: 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) elif task.team == Team.FRONTEND: col_map["frontend"].cards.append(card) diff --git a/tests/integration/test_kanban_service.py b/tests/integration/test_kanban_service.py index 0967eed1..efe4253c 100644 --- a/tests/integration/test_kanban_service.py +++ b/tests/integration/test_kanban_service.py @@ -67,7 +67,7 @@ def _seed(setup: dict, *, status: TaskStatus, **kw: Any) -> TaskTable: acceptance_criteria=["ac"], status=status, priority=kw.pop("priority", 2), - task_type=TaskType.CODE, + task_type=kw.pop("task_type", TaskType.CODE), nature=TaskNature.TECHNICAL, project_id=setup["project_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) assert card.subtask_count == 1 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