generated from coulomb/repo-seed
feat(tasks): adopt canonical task statuses
This commit is contained in:
@@ -10,11 +10,11 @@ from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
wait = "wait"
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
blocked = "blocked"
|
||||
progress = "progress"
|
||||
done = "done"
|
||||
cancelled = "cancelled"
|
||||
cancel = "cancel"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
|
||||
@@ -252,7 +252,7 @@ async def patch_request_status(
|
||||
# Auto-unblock the blocking task
|
||||
if req.blocking_task_id:
|
||||
task = await session.get(Task, req.blocking_task_id)
|
||||
if task and task.status == "blocked":
|
||||
if task and task.status == "wait":
|
||||
task.status = "todo"
|
||||
task.blocking_reason = None
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ async def workplan_stack(
|
||||
]
|
||||
blocked_tasks = [
|
||||
task_id for task_id in task_deps.get(ws.id, [])
|
||||
if task_status.get(task_id) not in {"done", "cancelled"}
|
||||
if task_status.get(task_id) not in {"done", "cancel"}
|
||||
]
|
||||
eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks
|
||||
if not include_blocked and not eligible:
|
||||
|
||||
@@ -17,6 +17,7 @@ from api.services.lifecycle import (
|
||||
transition_task_status,
|
||||
transition_workstream_status,
|
||||
)
|
||||
from api.task_status import TERMINAL_TASK_STATUSES
|
||||
from api.services.reconciliation import (
|
||||
ReconciliationClass,
|
||||
StateChangeClassification,
|
||||
@@ -52,7 +53,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
|
||||
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
||||
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
|
||||
statuses = [status_value(row[0]) for row in result.all()]
|
||||
return bool(statuses) and all(status in {"done", "cancelled"} for status in statuses)
|
||||
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
||||
|
||||
|
||||
def _deferred_message(
|
||||
|
||||
@@ -590,7 +590,7 @@ async def get_repo_dispatch(
|
||||
for ws in workstreams:
|
||||
task_result = await session.execute(
|
||||
select(Task)
|
||||
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "in_progress"]))
|
||||
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"]))
|
||||
.order_by(Task.created_at)
|
||||
)
|
||||
tasks = list(task_result.scalars().all())
|
||||
|
||||
@@ -38,6 +38,7 @@ from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||
from api.workplan_status import (
|
||||
CLOSED_WORKSTREAM_STATUSES,
|
||||
OPEN_WORKSTREAM_STATUSES,
|
||||
@@ -111,10 +112,10 @@ async def get_summary(
|
||||
)
|
||||
blocking = list(blocking_rows.scalars().all())
|
||||
|
||||
blocked_rows = await session.execute(
|
||||
select(Task).options(noload("*")).where(Task.status == TaskStatus.blocked).order_by(Task.created_at)
|
||||
waiting_rows = await session.execute(
|
||||
select(Task).options(noload("*")).where(Task.status == TaskStatus.wait).order_by(Task.created_at)
|
||||
)
|
||||
blocked = list(blocked_rows.scalars().all())
|
||||
waiting = list(waiting_rows.scalars().all())
|
||||
|
||||
recent_rows = await session.execute(
|
||||
select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20)
|
||||
@@ -136,7 +137,7 @@ async def get_summary(
|
||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||
):
|
||||
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
||||
task_statuses_per_ws.setdefault(ws_id, []).extend([_value(tstat)] * cnt)
|
||||
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
||||
|
||||
# Dependency graph for open workstreams
|
||||
open_ws_ids = [w.id for w in open_ws]
|
||||
@@ -263,11 +264,11 @@ async def get_summary(
|
||||
total=sum(ws_counts.values()),
|
||||
),
|
||||
tasks=TaskTotals(
|
||||
wait=task_counts.get(TaskStatus.wait, 0),
|
||||
todo=task_counts.get(TaskStatus.todo, 0),
|
||||
in_progress=task_counts.get(TaskStatus.in_progress, 0),
|
||||
blocked=task_counts.get(TaskStatus.blocked, 0),
|
||||
progress=task_counts.get(TaskStatus.progress, 0),
|
||||
done=task_counts.get(TaskStatus.done, 0),
|
||||
cancelled=task_counts.get(TaskStatus.cancelled, 0),
|
||||
cancel=task_counts.get(TaskStatus.cancel, 0),
|
||||
total=sum(task_counts.values()),
|
||||
),
|
||||
decisions=DecisionTotals(
|
||||
@@ -329,7 +330,8 @@ async def get_summary(
|
||||
for t in topics
|
||||
],
|
||||
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
|
||||
blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
|
||||
waiting_tasks=[TaskRead.model_validate(t) for t in waiting],
|
||||
blocked_tasks=[TaskRead.model_validate(t) for t in waiting],
|
||||
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
||||
next_steps=next_steps,
|
||||
domains=domain_summaries,
|
||||
@@ -343,10 +345,11 @@ async def get_summary(
|
||||
"status": effective_status.get(w.id, w.status),
|
||||
},
|
||||
tasks_total=sum(task_per_ws.get(w.id, {}).values()),
|
||||
tasks_wait=task_per_ws.get(w.id, {}).get(TaskStatus.wait, 0),
|
||||
tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0),
|
||||
tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0),
|
||||
tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0),
|
||||
tasks_progress=task_per_ws.get(w.id, {}).get(TaskStatus.progress, 0),
|
||||
tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0),
|
||||
tasks_cancel=task_per_ws.get(w.id, {}).get(TaskStatus.cancel, 0),
|
||||
depends_on=dep_index.get(w.id, {}).get("depends_on", []),
|
||||
blocks=dep_index.get(w.id, {}).get("blocks", []),
|
||||
blocked_reasons=blocked_reasons.get(w.id, []),
|
||||
@@ -521,7 +524,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
select(Task)
|
||||
.options(noload("*"))
|
||||
.where(Task.workstream_id == decision.workstream_id)
|
||||
.where(Task.status.in_([TaskStatus.todo, TaskStatus.in_progress]))
|
||||
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
|
||||
)
|
||||
open_tasks = list(open_tasks_rows.scalars().all())
|
||||
if not open_tasks:
|
||||
@@ -655,10 +658,6 @@ async def _get_domain_slug_for_topic(topic_id, session: AsyncSession) -> str | N
|
||||
return domain.slug if domain else None
|
||||
|
||||
|
||||
def _value(item):
|
||||
return item.value if hasattr(item, "value") else item
|
||||
|
||||
|
||||
@router.get("/next_steps", response_model=list[NextStep])
|
||||
async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]:
|
||||
"""Derive contextual next-action suggestions from current hub state.
|
||||
|
||||
@@ -11,6 +11,7 @@ from api.models.token_event import TokenEvent
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
||||
from api.services.lifecycle import status_value, transition_task_status
|
||||
from api.task_status import normalize_task_status
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
@@ -18,7 +19,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
@router.get("/", response_model=list[TaskRead])
|
||||
async def list_tasks(
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: TaskStatus | None = None,
|
||||
status: str | None = None,
|
||||
assignee: str | None = None,
|
||||
needs_human: bool | None = Query(None),
|
||||
priority: str | None = None,
|
||||
@@ -29,7 +30,7 @@ async def list_tasks(
|
||||
if workstream_id:
|
||||
q = q.where(Task.workstream_id == workstream_id)
|
||||
if status:
|
||||
q = q.where(Task.status == status)
|
||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||
if assignee:
|
||||
q = q.where(Task.assignee == assignee)
|
||||
if needs_human is not None:
|
||||
@@ -50,7 +51,7 @@ async def create_task(
|
||||
) -> Task:
|
||||
task = Task(**body.model_dump())
|
||||
session.add(task)
|
||||
if status_value(task.status) == "in_progress":
|
||||
if status_value(task.status) == "progress":
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
transition_task_status(
|
||||
task,
|
||||
@@ -198,7 +199,7 @@ async def cancel_task(
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
transition_task_status(task, TaskStatus.cancelled)
|
||||
transition_task_status(task, TaskStatus.cancel)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
|
||||
@@ -30,11 +30,11 @@ class WorkstreamTotals(BaseModel):
|
||||
|
||||
|
||||
class TaskTotals(BaseModel):
|
||||
wait: int = 0
|
||||
todo: int = 0
|
||||
in_progress: int = 0
|
||||
blocked: int = 0
|
||||
progress: int = 0
|
||||
done: int = 0
|
||||
cancelled: int = 0
|
||||
cancel: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ class StateSummary(BaseModel):
|
||||
totals: Totals
|
||||
topics: list[TopicWithWorkstreams]
|
||||
blocking_decisions: list[DecisionRead]
|
||||
blocked_tasks: list[TaskRead]
|
||||
waiting_tasks: list[TaskRead]
|
||||
blocked_tasks: list[TaskRead] = []
|
||||
recent_progress: list[ProgressEventRead]
|
||||
open_workstreams: list[WorkstreamWithDeps]
|
||||
next_steps: list[NextStep] = []
|
||||
|
||||
@@ -2,12 +2,22 @@ import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
from api.models.task import TaskPriority, TaskStatus
|
||||
from api.task_status import normalize_task_status
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
class TaskStatusMixin(BaseModel):
|
||||
@field_validator("status", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _normalize_status(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
return normalize_task_status(value)
|
||||
|
||||
|
||||
class TaskCreate(TaskStatusMixin):
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
@@ -27,7 +37,7 @@ class TaskCreate(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
class TaskUpdate(TaskStatusMixin):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
@@ -55,9 +65,9 @@ class TaskUpdate(BaseModel):
|
||||
suppress_token_event: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def blocking_reason_required_when_blocked(self) -> Self:
|
||||
if self.status == TaskStatus.blocked and not self.blocking_reason:
|
||||
raise ValueError("blocking_reason is required when status is blocked")
|
||||
def blocking_reason_required_when_human_waiting(self) -> Self:
|
||||
if self.status == TaskStatus.wait and self.needs_human and not self.blocking_reason:
|
||||
raise ValueError("blocking_reason is required when a human-blocked task is waiting")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -67,7 +77,7 @@ class TaskUpdate(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class TaskRead(BaseModel):
|
||||
class TaskRead(TaskStatusMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
workstream_id: uuid.UUID
|
||||
|
||||
@@ -92,10 +92,11 @@ class WorkstreamRead(WorkstreamStatusMixin):
|
||||
|
||||
class WorkstreamWithTaskCounts(WorkstreamRead):
|
||||
tasks_total: int = 0
|
||||
tasks_wait: int = 0
|
||||
tasks_todo: int = 0
|
||||
tasks_in_progress: int = 0
|
||||
tasks_blocked: int = 0
|
||||
tasks_progress: int = 0
|
||||
tasks_done: int = 0
|
||||
tasks_cancel: int = 0
|
||||
|
||||
|
||||
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
|
||||
|
||||
@@ -3,12 +3,13 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
|
||||
|
||||
TASK_STARTED_STATUS = "in_progress"
|
||||
TASK_STARTED_STATUS = "progress"
|
||||
TASK_NOT_STARTED_STATUS = "todo"
|
||||
TASK_ACTIVE_STATUSES = {"in_progress", "blocked"}
|
||||
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
|
||||
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
||||
|
||||
|
||||
@@ -21,12 +22,6 @@ class LifecycleTransitionResult:
|
||||
parent_activated: bool = False
|
||||
|
||||
|
||||
def status_value(status: Any) -> str:
|
||||
if hasattr(status, "value"):
|
||||
status = status.value
|
||||
return str(status or "").strip().lower()
|
||||
|
||||
|
||||
def should_activate_parent_for_task_start(
|
||||
*,
|
||||
previous_task_status: Any,
|
||||
@@ -109,8 +104,8 @@ def transition_task_status(
|
||||
if previous_task_status is None
|
||||
else previous_task_status
|
||||
)
|
||||
normalised_target = status_value(target_status)
|
||||
task.status = status_coercer(normalised_target) if status_coercer else target_status
|
||||
normalised_target = normalize_task_status(target_status)
|
||||
task.status = status_coercer(normalised_target) if status_coercer else normalised_target
|
||||
parent_activated = activate_parent_for_task_start(
|
||||
previous_task_status=previous_status,
|
||||
new_task_status=normalised_target,
|
||||
|
||||
@@ -123,7 +123,7 @@ async def collect_domain_activity(
|
||||
]
|
||||
attention_tasks = [
|
||||
task for task in tasks
|
||||
if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancelled.value}
|
||||
if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancel.value}
|
||||
]
|
||||
|
||||
data = {
|
||||
|
||||
@@ -5,6 +5,7 @@ from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from api.services.lifecycle import status_value
|
||||
from api.task_status import CANONICAL_TASK_STATUSES
|
||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
||||
|
||||
|
||||
@@ -22,7 +23,7 @@ class StateChangeClassification:
|
||||
|
||||
|
||||
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
|
||||
TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
||||
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
|
||||
|
||||
|
||||
def classify_workstream_status_change(
|
||||
@@ -129,11 +130,11 @@ def classify_task_status_change(
|
||||
"status is unchanged",
|
||||
"no file update required",
|
||||
)
|
||||
if target == "blocked" and not (blocking_reason or "").strip():
|
||||
if target == "wait" and not (blocking_reason or "").strip():
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.HUMAN_CONFIRMATION,
|
||||
"blocked tasks require a blocking reason",
|
||||
"capture the blocker before writing status",
|
||||
"waiting tasks should explain the wait condition",
|
||||
"capture the wait reason before writing status",
|
||||
)
|
||||
if target in TASK_STATUSES:
|
||||
return StateChangeClassification(
|
||||
|
||||
84
api/task_status.py
Normal file
84
api/task_status.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
CANONICAL_TASK_STATUSES: tuple[str, ...] = (
|
||||
"wait",
|
||||
"todo",
|
||||
"progress",
|
||||
"done",
|
||||
"cancel",
|
||||
)
|
||||
|
||||
TASK_STATUS_CODES: dict[str, str] = {
|
||||
"WAIT": "wait",
|
||||
"TODO": "todo",
|
||||
"PROG": "progress",
|
||||
"DONE": "done",
|
||||
"CNCL": "cancel",
|
||||
}
|
||||
|
||||
LEGACY_TASK_STATUS_ALIASES: dict[str, str] = {
|
||||
"blocked": "wait",
|
||||
"waiting": "wait",
|
||||
"in_progress": "progress",
|
||||
"in-progress": "progress",
|
||||
"prog": "progress",
|
||||
"cancelled": "cancel",
|
||||
"canceled": "cancel",
|
||||
"cncl": "cancel",
|
||||
}
|
||||
|
||||
OPEN_TASK_STATUSES: frozenset[str] = frozenset({"wait", "todo", "progress"})
|
||||
ACTIVE_TASK_STATUSES: frozenset[str] = frozenset({"wait", "progress"})
|
||||
TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({"done", "cancel"})
|
||||
|
||||
TASK_STATUS_ORDER: dict[str, int] = {
|
||||
"todo": 0,
|
||||
"wait": 1,
|
||||
"progress": 1,
|
||||
"done": 2,
|
||||
"cancel": 2,
|
||||
}
|
||||
|
||||
TASK_STATUS_LABELS: dict[str, str] = {
|
||||
"wait": "wait",
|
||||
"todo": "todo",
|
||||
"progress": "progress",
|
||||
"done": "done",
|
||||
"cancel": "cancel",
|
||||
}
|
||||
|
||||
|
||||
def raw_status_value(status: Any) -> str:
|
||||
if hasattr(status, "value"):
|
||||
status = status.value
|
||||
return str(status or "").strip()
|
||||
|
||||
|
||||
def normalize_task_status(status: Any, *, default: str | None = None) -> str:
|
||||
"""Normalize canon task statuses plus legacy aliases to stored values."""
|
||||
value = raw_status_value(status).lower()
|
||||
if not value:
|
||||
if default is not None:
|
||||
return default
|
||||
raise ValueError("task status is required")
|
||||
value = LEGACY_TASK_STATUS_ALIASES.get(value, value)
|
||||
if value not in CANONICAL_TASK_STATUSES:
|
||||
allowed = ", ".join(CANONICAL_TASK_STATUSES)
|
||||
aliases = ", ".join(sorted(LEGACY_TASK_STATUS_ALIASES))
|
||||
raise ValueError(f"task status must be one of {allowed}; legacy aliases: {aliases}")
|
||||
return value
|
||||
|
||||
|
||||
def status_value(status: Any, *, default: str | None = None) -> str:
|
||||
"""Compatibility wrapper used by lifecycle and reconciliation code."""
|
||||
return normalize_task_status(status, default=default)
|
||||
|
||||
|
||||
def is_open_task_status(status: Any) -> bool:
|
||||
return normalize_task_status(status, default="todo") in OPEN_TASK_STATUSES
|
||||
|
||||
|
||||
def is_terminal_task_status(status: Any) -> bool:
|
||||
return normalize_task_status(status, default="todo") in TERMINAL_TASK_STATUSES
|
||||
Reference in New Issue
Block a user