CUST-WP-0074-T02 follow-up: blocked_on / wait_until reach the hub read model
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Build and Publish Multi-Context Image / build-and-push (push) Waiting to run

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:
tegwick 2026-09-29 21:38:27 +02:00
parent 566a6c9a64
commit a495a27e0e
9 changed files with 306 additions and 19 deletions

View file

@ -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"),

View file

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

View file

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

View file

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

View file

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

View 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")

View file

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

View file

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

View file

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