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

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