CUST-WP-0074-T02: task wait qualifiers in the hub read model
Adds tasks.decision_id and workplan_dependencies.from_task_id (migration
e8f9a0b1c2d3). A from_task_id column is needed rather than
from_workplan_id plus description: the partial unique indexes key on
(from_workplan_id, target, relationship_type), so two tasks in one
workplan waiting on the same target could not both be indexed, and the
derived wait_kind needs per-task attribution. The two existing unique
indexes are narrowed to frontmatter edges (from_task_id IS NULL) and two
task-origin counterparts are added; downgrade deletes task-origin rows
before restoring the old indexes.
POST /workplans/{id}/dependencies/ accepts from_task_id (must belong to
the from workplan; a task cannot depend on itself). TaskCreate/Update/Read
carry decision_id.
Derived read-model fields, no new write routes (rule 5):
- TaskRead.wait_kind: external | human | both | unqualified, null unless
status is wait (dependency rows from this task / needs_human).
- WorkplanRead.blocked_kind: human | external | none, null unless status
is blocked. Human wins over external.
Both come from api/services/wait_kind.py, applied on GET /tasks/,
GET /tasks/{id}, GET /workplans/ and GET /workplans/{id}.
The identifier-migration reference-count proof now includes from_task_id
(FK count guard 22 -> 23).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 237582@bnt-lap001
Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826
This commit is contained in:
parent
e92471df3b
commit
b9997d6fc7
13 changed files with 400 additions and 6 deletions
107
api/services/wait_kind.py
Normal file
107
api/services/wait_kind.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Derived wait qualifiers (CUST-WP-0074 rule 5) — read model only.
|
||||
|
||||
A ``wait`` task is *external* when it carries at least one dependency edge
|
||||
(``workplan_dependencies.from_task_id``), *human* when ``needs_human`` is set,
|
||||
*both* when both hold and *unqualified* otherwise. A ``blocked`` workplan is
|
||||
*human* when any of its wait tasks is human or both, else *external* when any
|
||||
is external, else *none*. Nothing here is stored; the routers call the
|
||||
``annotate_*`` helpers and Pydantic picks the attributes up on serialisation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.task_status import normalize_task_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
WAIT_KIND_EXTERNAL = "external"
|
||||
WAIT_KIND_HUMAN = "human"
|
||||
WAIT_KIND_BOTH = "both"
|
||||
WAIT_KIND_UNQUALIFIED = "unqualified"
|
||||
|
||||
BLOCKED_KIND_EXTERNAL = "external"
|
||||
BLOCKED_KIND_HUMAN = "human"
|
||||
BLOCKED_KIND_NONE = "none"
|
||||
|
||||
|
||||
def derive_wait_kind(status: Any, *, needs_human: bool, has_dependency: bool) -> str | None:
|
||||
"""Rule 1 / rule 5 for one task. ``None`` unless the task is waiting."""
|
||||
if normalize_task_status(status, default="todo") != "wait":
|
||||
return None
|
||||
if has_dependency and needs_human:
|
||||
return WAIT_KIND_BOTH
|
||||
if needs_human:
|
||||
return WAIT_KIND_HUMAN
|
||||
if has_dependency:
|
||||
return WAIT_KIND_EXTERNAL
|
||||
return WAIT_KIND_UNQUALIFIED
|
||||
|
||||
|
||||
def derive_blocked_kind(status: Any, wait_kinds: Iterable[str | None]) -> str | None:
|
||||
"""Rule 5 for one workplan. ``None`` unless the workplan is blocked."""
|
||||
if normalize_workplan_status(status) != "blocked":
|
||||
return None
|
||||
kinds = {kind for kind in wait_kinds if kind}
|
||||
if WAIT_KIND_HUMAN in kinds or WAIT_KIND_BOTH in kinds:
|
||||
return BLOCKED_KIND_HUMAN
|
||||
if WAIT_KIND_EXTERNAL in kinds:
|
||||
return BLOCKED_KIND_EXTERNAL
|
||||
return BLOCKED_KIND_NONE
|
||||
|
||||
|
||||
async def task_ids_with_dependencies(
|
||||
session: AsyncSession, task_ids: Sequence[uuid.UUID]
|
||||
) -> set[uuid.UUID]:
|
||||
if not task_ids:
|
||||
return set()
|
||||
rows = await session.execute(
|
||||
select(WorkplanDependency.from_task_id).where(
|
||||
WorkplanDependency.from_task_id.in_(list(task_ids))
|
||||
)
|
||||
)
|
||||
return {row[0] for row in rows if row[0] is not None}
|
||||
|
||||
|
||||
async def annotate_tasks(session: AsyncSession, tasks: Sequence[Task]) -> Sequence[Task]:
|
||||
"""Set ``task.wait_kind`` on each ORM task (serialised by TaskRead)."""
|
||||
waiting = [t for t in tasks if normalize_task_status(t.status, default="todo") == "wait"]
|
||||
with_deps = await task_ids_with_dependencies(session, [t.id for t in waiting])
|
||||
for task in tasks:
|
||||
task.wait_kind = derive_wait_kind(
|
||||
task.status,
|
||||
needs_human=bool(task.needs_human),
|
||||
has_dependency=task.id in with_deps,
|
||||
)
|
||||
return tasks
|
||||
|
||||
|
||||
async def annotate_workplans(
|
||||
session: AsyncSession, workplans: Sequence[Workplan]
|
||||
) -> Sequence[Workplan]:
|
||||
"""Set ``workplan.blocked_kind`` on each ORM workplan (serialised by WorkplanRead)."""
|
||||
blocked = [
|
||||
wp for wp in workplans if normalize_workplan_status(wp.status) == "blocked"
|
||||
]
|
||||
kinds_by_workplan: dict[uuid.UUID, list[str | None]] = {wp.id: [] for wp in blocked}
|
||||
if blocked:
|
||||
rows = await session.execute(
|
||||
select(Task).where(
|
||||
Task.workplan_id.in_(list(kinds_by_workplan)),
|
||||
Task.status == TaskStatus.wait,
|
||||
)
|
||||
)
|
||||
wait_tasks = list(rows.scalars().all())
|
||||
await annotate_tasks(session, wait_tasks)
|
||||
for task in wait_tasks:
|
||||
kinds_by_workplan[task.workplan_id].append(task.wait_kind)
|
||||
for wp in workplans:
|
||||
wp.blocked_kind = derive_blocked_kind(wp.status, kinds_by_workplan.get(wp.id, []))
|
||||
return workplans
|
||||
|
|
@ -386,6 +386,7 @@ async def _task_reference_count(session: AsyncSession, task_id: uuid.UUID) -> in
|
|||
"(SELECT count(*) FROM progress_events WHERE task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM capability_requests WHERE blocking_task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM workplan_dependencies WHERE to_task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM workplan_dependencies WHERE from_task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM suggestions WHERE promoted_task_id = :task_id)"
|
||||
),
|
||||
{"task_id": task_id},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue