CUST-WP-0074-T02 follow-up: blocked_on / wait_until reach the hub read model
Migration f9a0b1c2d3e4 (revises e8f9a0b1c2d3) adds tasks.blocked_on (String 200) and tasks.wait_until (Date); TaskCreate/Update/Read carry both. Read model (api/services/wait_kind.py): a wait task is external when it has a dependency edge, a blocked_on matching ^message-from: (rule 6), or a wait_until date (a window clears by itself). human/both/unqualified and blocked_kind are unchanged in shape. fix-consistency: _task_wait_fields carries blocked_on and wait_until (ISO date, PyYAML date objects normalised), so the C-06/C-11 creates and the C-10 status PATCH send them, and C-41 compares and syncs them like the other qualifiers. C-39 accepts wait_until: YYYY-MM-DD as a qualifier; C-40 also fires when wait_until is before UTC today (report only). /state/deps: depends_on stubs gain from_task_id / from_task_title so consumers see task-scoped edges; both optional, response otherwise as before. 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
566a6c9a64
commit
a495a27e0e
9 changed files with 306 additions and 19 deletions
|
|
@ -60,6 +60,10 @@ class Task(Base, TimestampMixin):
|
|||
# "CCR-2026-0004" (CUST-WP-0074 Case B). Free text: the decision may live
|
||||
# in another repo and may not be indexed in the hub yet.
|
||||
decision_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
# Owner-return marker "message-from:<agent>" (CUST-WP-0074 rule 6) and a
|
||||
# date after which the wait clears by itself; either counts as external.
|
||||
blocked_on: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
wait_until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
|
|
|
|||
|
|
@ -909,12 +909,15 @@ async def get_deps(
|
|||
|
||||
dep_ws_ids: set = set()
|
||||
dep_task_ids: set = set()
|
||||
open_ws_id_set = set(open_ws_ids)
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workplan_id)
|
||||
if d.to_workplan_id:
|
||||
dep_ws_ids.add(d.to_workplan_id)
|
||||
if d.to_task_id:
|
||||
dep_task_ids.add(d.to_task_id)
|
||||
if d.from_task_id and d.from_workplan_id in open_ws_id_set:
|
||||
dep_task_ids.add(d.from_task_id)
|
||||
|
||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||
|
|
@ -933,16 +936,25 @@ async def get_deps(
|
|||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||
for d in dep_rows:
|
||||
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||
# Task-scoped edge (CUST-WP-0074): the waiting task on the from side,
|
||||
# only surfaced on the workplan's own depends_on stubs.
|
||||
from_task = task_lookup.get(d.from_task_id) if d.from_task_id else None
|
||||
from_task_fields = {
|
||||
"from_task_id": d.from_task_id,
|
||||
"from_task_title": from_task.title if from_task is not None else None,
|
||||
}
|
||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
||||
workstream_id=to_id, workstream_slug=ws_lookup[to_id].slug,
|
||||
workstream_title=ws_lookup[to_id].title, description=d.description,
|
||||
**from_task_fields,
|
||||
))
|
||||
if from_id in dep_index and task_id and task_id in task_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id, target_type="task", relationship_type=d.relationship_type,
|
||||
task_id=task_id, task_title=task_lookup[task_id].title, description=d.description,
|
||||
**from_task_fields,
|
||||
))
|
||||
if to_id and to_id in dep_index and from_id in ws_lookup:
|
||||
dep_index[to_id]["blocks"].append(WorkstreamDepStub(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin):
|
|||
needs_human: bool = False
|
||||
intervention_note: str | None = None
|
||||
decision_id: str | None = None
|
||||
blocked_on: str | None = None
|
||||
wait_until: date | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
@ -67,6 +69,8 @@ class TaskUpdate(TaskStatusMixin, TaskFlavorMixin):
|
|||
needs_human: bool | None = None
|
||||
intervention_note: str | None = None
|
||||
decision_id: str | None = None
|
||||
blocked_on: str | None = None
|
||||
wait_until: date | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
# Token passthrough — three tiers (highest precision wins):
|
||||
# 1. tokens_in + tokens_out → exact counts; note defaults to "measured"
|
||||
|
|
@ -131,6 +135,8 @@ class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
|||
needs_human: bool
|
||||
intervention_note: str | None = None
|
||||
decision_id: str | None = None
|
||||
blocked_on: str | None = None
|
||||
wait_until: date | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
# Derived (CUST-WP-0074 rule 5): "external" | "human" | "both" |
|
||||
# "unqualified" while status is wait, else None. Set by
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ class WorkplanDepStub(BaseModel):
|
|||
)
|
||||
task_id: uuid.UUID | None = None
|
||||
task_title: str | None = None
|
||||
# The waiting task on a task-scoped edge (workplan_dependencies.from_task_id);
|
||||
# None on frontmatter edges.
|
||||
from_task_id: uuid.UUID | None = None
|
||||
from_task_title: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
"""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
|
||||
(``workplan_dependencies.from_task_id``), an owner-return marker
|
||||
(``blocked_on: message-from:<agent>``, rule 6) or a ``wait_until`` date (a
|
||||
time window clears by itself); *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 re
|
||||
import uuid
|
||||
from collections.abc import Iterable, Sequence
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
|
@ -31,16 +35,41 @@ BLOCKED_KIND_EXTERNAL = "external"
|
|||
BLOCKED_KIND_HUMAN = "human"
|
||||
BLOCKED_KIND_NONE = "none"
|
||||
|
||||
# Rule 6 owner-return marker; mirrors _BLOCKED_ON_RE in scripts/consistency_check.py.
|
||||
MESSAGE_FROM_RE = re.compile(r"^message-from:.+$")
|
||||
|
||||
def derive_wait_kind(status: Any, *, needs_human: bool, has_dependency: bool) -> str | None:
|
||||
|
||||
def is_external_wait(
|
||||
*,
|
||||
has_dependency: bool,
|
||||
blocked_on: str | None = None,
|
||||
wait_until: date | None = None,
|
||||
) -> bool:
|
||||
"""Case A holds for a dependency edge, an owner-return marker or a wait window."""
|
||||
if has_dependency or wait_until is not None:
|
||||
return True
|
||||
return bool(blocked_on and MESSAGE_FROM_RE.match(blocked_on.strip()))
|
||||
|
||||
|
||||
def derive_wait_kind(
|
||||
status: Any,
|
||||
*,
|
||||
needs_human: bool,
|
||||
has_dependency: bool,
|
||||
blocked_on: str | None = None,
|
||||
wait_until: date | None = None,
|
||||
) -> 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:
|
||||
external = is_external_wait(
|
||||
has_dependency=has_dependency, blocked_on=blocked_on, wait_until=wait_until
|
||||
)
|
||||
if external and needs_human:
|
||||
return WAIT_KIND_BOTH
|
||||
if needs_human:
|
||||
return WAIT_KIND_HUMAN
|
||||
if has_dependency:
|
||||
if external:
|
||||
return WAIT_KIND_EXTERNAL
|
||||
return WAIT_KIND_UNQUALIFIED
|
||||
|
||||
|
|
@ -79,6 +108,8 @@ async def annotate_tasks(session: AsyncSession, tasks: Sequence[Task]) -> Sequen
|
|||
task.status,
|
||||
needs_human=bool(task.needs_human),
|
||||
has_dependency=task.id in with_deps,
|
||||
blocked_on=task.blocked_on,
|
||||
wait_until=task.wait_until,
|
||||
)
|
||||
return tasks
|
||||
|
||||
|
|
|
|||
27
migrations/versions/f9a0b1c2d3e4_task_wait_window.py
Normal file
27
migrations/versions/f9a0b1c2d3e4_task_wait_window.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""task wait window and owner-return marker (CUST-WP-0074-T02 follow-up)
|
||||
|
||||
Adds ``tasks.blocked_on`` (the task-block ``blocked_on: message-from:<agent>``
|
||||
owner-return marker, rule 6) and ``tasks.wait_until`` (a date after which a
|
||||
wait clears by itself) so that both qualifiers reach the hub read model
|
||||
instead of living only in the workplan file.
|
||||
|
||||
Revision ID: f9a0b1c2d3e4
|
||||
Revises: e8f9a0b1c2d3
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "f9a0b1c2d3e4"
|
||||
down_revision = "e8f9a0b1c2d3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("tasks", sa.Column("blocked_on", sa.String(length=200), nullable=True))
|
||||
op.add_column("tasks", sa.Column("wait_until", sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tasks", "wait_until")
|
||||
op.drop_column("tasks", "blocked_on")
|
||||
|
|
@ -42,9 +42,9 @@ Checks:
|
|||
C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list
|
||||
C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref
|
||||
C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins)
|
||||
C-39 task-wait-unqualified WARN No wait task has neither task-block depends_on nor needs_human: true (CUST-WP-0074 rule 1)
|
||||
C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, or whose decision_id is resolved (CUST-WP-0074 rules 3/4)
|
||||
C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id differ between file and DB (file wins)
|
||||
C-39 task-wait-unqualified WARN No wait task has none of task-block depends_on, needs_human: true, blocked_on: message-from:<agent>, wait_until: YYYY-MM-DD (CUST-WP-0074 rules 1/6)
|
||||
C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, whose decision_id is resolved, or whose wait_until has passed (CUST-WP-0074 rules 3/4)
|
||||
C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id/blocked_on/wait_until differ between file and DB (file wins)
|
||||
(C-20 also indexes task-block depends_on as workplan_dependencies rows with from_task_id set)
|
||||
(finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood)
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ import uuid
|
|||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date as _date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -181,6 +181,7 @@ _WP_FILE_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE)
|
|||
_WP_ID_PREFIX_RE = re.compile(r"^([A-Z][A-Z0-9-]*-WP)-\d+", re.IGNORECASE)
|
||||
_WP_ID_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE)
|
||||
_BLOCKED_ON_RE = re.compile(r"^message-from:(?P<agent>.+)$")
|
||||
_WAIT_UNTIL_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_WORK_REQUEST_RE = re.compile(
|
||||
r"\b(implement|workplan|multi-?step|please\s+(implement|add|create|fix|build))\b",
|
||||
re.IGNORECASE,
|
||||
|
|
@ -562,18 +563,41 @@ def _as_bool(value: Any) -> bool:
|
|||
return str(value).strip().lower() in {"true", "yes", "1", "on"}
|
||||
|
||||
|
||||
def _task_wait_until(task: dict) -> str | None:
|
||||
"""``wait_until`` as an ISO date string, or None when absent or not a
|
||||
``YYYY-MM-DD`` date (PyYAML hands a bare date over as ``datetime.date``)."""
|
||||
raw = task.get("wait_until")
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
if isinstance(raw, datetime):
|
||||
raw = raw.date()
|
||||
if isinstance(raw, _date):
|
||||
return raw.isoformat()
|
||||
text = str(raw).strip().strip('"').strip("'")
|
||||
if not _WAIT_UNTIL_RE.match(text):
|
||||
return None
|
||||
try:
|
||||
return _date.fromisoformat(text).isoformat()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _task_wait_fields(task: dict) -> dict[str, Any]:
|
||||
"""File → hub fields that qualify a wait (CUST-WP-0074): needs_human,
|
||||
blocking_reason, decision_id. TaskCreate/TaskUpdate require an
|
||||
intervention_note whenever needs_human is set; the blocking reason is by
|
||||
rule 2 what the human must do, so it stands in when the block has none."""
|
||||
blocking_reason, decision_id, blocked_on, wait_until. TaskCreate/TaskUpdate
|
||||
require an intervention_note whenever needs_human is set; the blocking
|
||||
reason is by rule 2 what the human must do, so it stands in when the block
|
||||
has none."""
|
||||
needs_human = _as_bool(task.get("needs_human"))
|
||||
blocking_reason = str(task.get("blocking_reason") or "").strip() or None
|
||||
decision_id = str(task.get("decision_id") or "").strip().strip('"') or None
|
||||
blocked_on = str(task.get("blocked_on") or "").strip().strip('"') or None
|
||||
fields: dict[str, Any] = {
|
||||
"needs_human": needs_human,
|
||||
"blocking_reason": blocking_reason,
|
||||
"decision_id": decision_id,
|
||||
"blocked_on": blocked_on,
|
||||
"wait_until": _task_wait_until(task),
|
||||
}
|
||||
if needs_human:
|
||||
fields["intervention_note"] = (
|
||||
|
|
@ -582,7 +606,7 @@ def _task_wait_fields(task: dict) -> dict[str, Any]:
|
|||
return fields
|
||||
|
||||
|
||||
_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id")
|
||||
_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id", "blocked_on", "wait_until")
|
||||
|
||||
|
||||
def _dependency_target_satisfied(record_id: str, status: str) -> bool:
|
||||
|
|
@ -2018,21 +2042,37 @@ def check_repo(
|
|||
wait_fields = _task_wait_fields(task)
|
||||
if t_status == "wait" and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES:
|
||||
# Rule 6: an owner-return wait with no workplan id yet is
|
||||
# qualified by the C-25 vocabulary on the task block.
|
||||
owner_return = bool(
|
||||
_BLOCKED_ON_RE.match(str(task.get("blocked_on") or "").strip())
|
||||
)
|
||||
if not task_dep_targets and not wait_fields["needs_human"] and not owner_return:
|
||||
# qualified by the C-25 vocabulary on the task block; a
|
||||
# wait_until date is a window that clears by itself.
|
||||
owner_return = bool(_BLOCKED_ON_RE.match(wait_fields["blocked_on"] or ""))
|
||||
wait_until = wait_fields["wait_until"]
|
||||
if (
|
||||
not task_dep_targets and not wait_fields["needs_human"]
|
||||
and not owner_return and not wait_until
|
||||
):
|
||||
report.add(
|
||||
severity="WARN", check_id="C-39",
|
||||
message=(
|
||||
f"Wait task '{t_id}' in workplan '{meta.get('id', fname)}' has "
|
||||
f"neither depends_on nor needs_human: true — unqualified wait"
|
||||
f"neither depends_on, needs_human: true, blocked_on: message-from:… "
|
||||
f"nor wait_until — unqualified wait"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
file_value=t_status,
|
||||
fixable=False,
|
||||
)
|
||||
# C-40: rule 3 for a wait window — the date has passed (UTC today).
|
||||
if wait_until and _date.fromisoformat(wait_until) < datetime.now(timezone.utc).date():
|
||||
report.add(
|
||||
severity="WARN", check_id="C-40",
|
||||
message=(
|
||||
f"blocker satisfied, task still wait: '{t_id}' in workplan "
|
||||
f"'{meta.get('id', fname)}' — wait_until {wait_until} has passed"
|
||||
),
|
||||
file_path=f"{fname}#{t_id}",
|
||||
file_value=wait_until,
|
||||
fixable=False,
|
||||
)
|
||||
# C-40: rule 3 — every depends_on target terminal. A target the
|
||||
# hub cannot resolve is skipped, which also withholds the warning.
|
||||
resolved_targets = [
|
||||
|
|
@ -2251,6 +2291,8 @@ def check_repo(
|
|||
"needs_human": bool(db_task.get("needs_human")),
|
||||
"blocking_reason": str(db_task.get("blocking_reason") or "").strip() or None,
|
||||
"decision_id": str(db_task.get("decision_id") or "").strip() or None,
|
||||
"blocked_on": str(db_task.get("blocked_on") or "").strip() or None,
|
||||
"wait_until": str(db_task.get("wait_until") or "").strip() or None,
|
||||
}
|
||||
file_wait_fields = {key: wait_fields[key] for key in _TASK_WAIT_FIELD_KEYS}
|
||||
if file_wait_fields != db_wait_fields:
|
||||
|
|
|
|||
|
|
@ -2364,6 +2364,8 @@ class TestTaskWaitQualifiers:
|
|||
"needs_human": True,
|
||||
"blocking_reason": "operator must provision creds",
|
||||
"decision_id": "CCR-2026-0004",
|
||||
"blocked_on": None,
|
||||
"wait_until": None,
|
||||
"intervention_note": "operator must provision creds",
|
||||
},
|
||||
}
|
||||
|
|
@ -2373,6 +2375,73 @@ class TestTaskWaitQualifiers:
|
|||
self._install(monkeypatch, self._fake_get(repo, db_task=db_task))
|
||||
assert self._issues(check_repo("http://unused", "demo-repo"), "C-41") == []
|
||||
|
||||
def test_c41_syncs_blocked_on_and_wait_until(self, tmp_path, monkeypatch):
|
||||
repo = self._make_repo(
|
||||
tmp_path,
|
||||
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
|
||||
"state_hub_task_id: \"task-1\"\nblocked_on: message-from:fin-hub\n"
|
||||
"wait_until: 2099-01-31\nblocking_reason: \"fin-hub owner returns an id\"",
|
||||
)
|
||||
db_task = {"id": "task-1", "status": "wait", "needs_human": False,
|
||||
"blocking_reason": "fin-hub owner returns an id", "decision_id": None,
|
||||
"blocked_on": None, "wait_until": None}
|
||||
self._install(monkeypatch, self._fake_get(repo, db_task=db_task))
|
||||
|
||||
report = check_repo("http://unused", "demo-repo")
|
||||
|
||||
c41 = self._issues(report, "C-41")
|
||||
assert len(c41) == 1 and c41[0].fixable is True
|
||||
assert c41[0]._fix_context["wait_fields"] == {
|
||||
"needs_human": False,
|
||||
"blocking_reason": "fin-hub owner returns an id",
|
||||
"decision_id": None,
|
||||
"blocked_on": "message-from:fin-hub",
|
||||
"wait_until": "2099-01-31",
|
||||
}
|
||||
|
||||
# The hub echoes the date as ISO text; in sync → silent.
|
||||
db_task.update(blocked_on="message-from:fin-hub", wait_until="2099-01-31")
|
||||
self._install(monkeypatch, self._fake_get(repo, db_task=db_task))
|
||||
assert self._issues(check_repo("http://unused", "demo-repo"), "C-41") == []
|
||||
|
||||
def test_wait_until_qualifies_c39_and_past_date_warns_c40(self, tmp_path, monkeypatch):
|
||||
repo = self._make_repo(
|
||||
tmp_path,
|
||||
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
|
||||
"state_hub_task_id: \"task-1\"\nwait_until: 2099-01-31\n"
|
||||
"blocking_reason: \"embargo until the window opens\"",
|
||||
)
|
||||
self._install(monkeypatch, self._fake_get(repo))
|
||||
report = check_repo("http://unused", "demo-repo")
|
||||
assert self._issues(report, "C-39") == []
|
||||
assert self._issues(report, "C-40") == []
|
||||
|
||||
repo = self._make_repo(
|
||||
tmp_path / "past",
|
||||
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
|
||||
"state_hub_task_id: \"task-1\"\nwait_until: \"2020-01-31\"\n"
|
||||
"blocking_reason: \"embargo until the window opens\"",
|
||||
)
|
||||
self._install(monkeypatch, self._fake_get(repo))
|
||||
report = check_repo("http://unused", "demo-repo")
|
||||
assert self._issues(report, "C-39") == []
|
||||
c40 = self._issues(report, "C-40")
|
||||
assert len(c40) == 1 and c40[0].fixable is False
|
||||
assert c40[0].message.startswith("blocker satisfied, task still wait: 'STATE-WP-0001-T01'")
|
||||
assert "wait_until 2020-01-31 has passed" in c40[0].message
|
||||
|
||||
# Not a YYYY-MM-DD date → no qualifier, C-39 as before.
|
||||
repo = self._make_repo(
|
||||
tmp_path / "junk",
|
||||
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
|
||||
"state_hub_task_id: \"task-1\"\nwait_until: soon\n"
|
||||
"blocking_reason: \"embargo until the window opens\"",
|
||||
)
|
||||
self._install(monkeypatch, self._fake_get(repo))
|
||||
report = check_repo("http://unused", "demo-repo")
|
||||
assert len(self._issues(report, "C-39")) == 1
|
||||
assert self._issues(report, "C-40") == []
|
||||
|
||||
def test_c41_ignores_blocks_that_do_not_carry_the_keys(self, tmp_path, monkeypatch):
|
||||
repo = self._make_repo(
|
||||
tmp_path,
|
||||
|
|
@ -2431,6 +2500,8 @@ class TestTaskWaitQualifiers:
|
|||
"needs_human": False,
|
||||
"blocking_reason": "access-engine rename must land first",
|
||||
"decision_id": None,
|
||||
"blocked_on": None,
|
||||
"wait_until": None,
|
||||
}
|
||||
assert ("/tasks/task-1", {"status": "wait", "suppress_token_event": True, **wait_fields}) in patches
|
||||
assert ("/tasks/task-1", wait_fields) in patches
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
from_task_id dependency edge. Real PostgreSQL via the shared `client` fixture."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.wait_kind import derive_blocked_kind, derive_wait_kind
|
||||
|
|
@ -16,6 +18,23 @@ class TestDeriveFunctions:
|
|||
assert derive_wait_kind("wait", needs_human=True, has_dependency=False) == "human"
|
||||
assert derive_wait_kind("wait", needs_human=True, has_dependency=True) == "both"
|
||||
|
||||
def test_owner_return_marker_and_wait_window_are_external(self):
|
||||
assert derive_wait_kind(
|
||||
"wait", needs_human=False, has_dependency=False, blocked_on="message-from:fin-hub"
|
||||
) == "external"
|
||||
assert derive_wait_kind(
|
||||
"wait", needs_human=False, has_dependency=False, blocked_on="fin-hub"
|
||||
) == "unqualified"
|
||||
assert derive_wait_kind(
|
||||
"wait", needs_human=False, has_dependency=False, wait_until=date(2099, 1, 31)
|
||||
) == "external"
|
||||
assert derive_wait_kind(
|
||||
"wait", needs_human=True, has_dependency=False, wait_until=date(2020, 1, 31)
|
||||
) == "both"
|
||||
assert derive_wait_kind(
|
||||
"todo", needs_human=False, has_dependency=False, blocked_on="message-from:x"
|
||||
) is None
|
||||
|
||||
def test_blocked_kind_prefers_human(self):
|
||||
assert derive_blocked_kind("active", ["human"]) is None
|
||||
assert derive_blocked_kind("blocked", []) == "none"
|
||||
|
|
@ -124,3 +143,74 @@ async def test_two_tasks_may_wait_on_the_same_target(client):
|
|||
assert r.status_code == 201, r.text
|
||||
r = await client.get(f"/workplans/{wp['id']}/dependencies/")
|
||||
assert sorted(row["from_task_id"] for row in r.json()) == sorted([t1["id"], t2["id"]])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_on_and_wait_until_reach_the_read_model(client):
|
||||
domain = await create_test_domain(client)
|
||||
repo = await create_test_repo(client, domain_slug=domain["slug"])
|
||||
blocked = await create_test_workplan(client, repo["id"], slug="blocked", status="blocked")
|
||||
|
||||
owner_return = await _task(
|
||||
client, blocked["id"], "owner return", status="wait",
|
||||
blocking_reason="fin-hub owner returns an id", blocked_on="message-from:fin-hub",
|
||||
)
|
||||
window = await _task(
|
||||
client, blocked["id"], "window", status="wait",
|
||||
blocking_reason="embargo", wait_until="2099-01-31",
|
||||
)
|
||||
plain = await _task(
|
||||
client, blocked["id"], "plain", status="wait", blocking_reason="?", blocked_on="fin-hub",
|
||||
)
|
||||
|
||||
r = await client.get("/tasks/", params={"workplan_id": blocked["id"]})
|
||||
rows = {row["title"]: row for row in r.json()}
|
||||
assert rows["owner return"]["wait_kind"] == "external"
|
||||
assert rows["owner return"]["blocked_on"] == "message-from:fin-hub"
|
||||
assert rows["window"]["wait_kind"] == "external"
|
||||
assert rows["window"]["wait_until"] == "2099-01-31"
|
||||
assert rows["plain"]["wait_kind"] == "unqualified"
|
||||
r = await client.get(f"/workplans/{blocked['id']}")
|
||||
assert r.json()["blocked_kind"] == "external"
|
||||
|
||||
# Clearing the window on PATCH drops the qualifier again.
|
||||
r = await client.patch(f"/tasks/{window['id']}", json={"wait_until": None})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["wait_until"] is None
|
||||
r = await client.get(f"/tasks/{window['id']}")
|
||||
assert r.json()["wait_kind"] == "unqualified"
|
||||
del owner_return, plain
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_deps_stubs_carry_the_waiting_task(client):
|
||||
domain = await create_test_domain(client)
|
||||
repo = await create_test_repo(client, domain_slug=domain["slug"])
|
||||
upstream = await create_test_workplan(client, repo["id"], slug="upstream", status="active")
|
||||
waiting = await create_test_workplan(client, repo["id"], slug="waiting", status="blocked")
|
||||
upstream_task = await _task(client, upstream["id"], "upstream task")
|
||||
waiter = await _task(client, waiting["id"], "waiter", status="wait", blocking_reason="upstream")
|
||||
|
||||
for body in (
|
||||
{"from_task_id": waiter["id"], "to_workplan_id": upstream["id"]},
|
||||
{"from_task_id": waiter["id"], "to_task_id": upstream_task["id"]},
|
||||
{"to_workplan_id": upstream["id"], "relationship_type": "related"},
|
||||
):
|
||||
r = await client.post(f"/workplans/{waiting['id']}/dependencies/", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
deps = {row["id"]: row for row in (await client.get("/state/deps")).json()}
|
||||
stubs = {
|
||||
(stub["target_type"], stub["relationship_type"]): stub
|
||||
for stub in deps[waiting["id"]]["depends_on"]
|
||||
}
|
||||
assert stubs[("workstream", "blocks")]["from_task_id"] == waiter["id"]
|
||||
assert stubs[("workstream", "blocks")]["from_task_title"] == "waiter"
|
||||
assert stubs[("task", "blocks")]["from_task_id"] == waiter["id"]
|
||||
assert stubs[("task", "blocks")]["task_id"] == upstream_task["id"]
|
||||
assert stubs[("workstream", "related")]["from_task_id"] is None
|
||||
assert stubs[("workstream", "related")]["from_task_title"] is None
|
||||
# The reverse side is unchanged: blocks stubs name the workplan only.
|
||||
for stub in deps[upstream["id"]]["blocks"]:
|
||||
assert stub["workstream_id"] == waiting["id"]
|
||||
assert stub["from_task_id"] is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue