feat(tasks): adopt canonical task statuses

This commit is contained in:
2026-05-26 01:32:50 +02:00
parent da5aee6e38
commit 38835e9e79
61 changed files with 692 additions and 342 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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(

View File

@@ -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())

View File

@@ -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.

View File

@@ -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