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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue