CUST-WP-0074-T02: C-39/C-40/C-41 and task-block depends_on in C-20

consistency_check.py:
- C-20 now indexes each ```task block's depends_on (workplan ids and
  task ids) as workplan_dependencies rows with from_task_id set,
  description "<task> depends_on <target>". Targets may live in other
  repos: resolved by file mapping first, ADR-007 derived uuid second;
  unresolvable targets stay a non-fixable C-20 as before. Frontmatter
  edges are unchanged (keyed with from_task_id None).
- C-39 task-wait-unqualified (WARN, not fixable): wait task with neither
  task-block depends_on nor needs_human: true. Skipped in closed
  workplans.
- C-40 task-wait-blocker-satisfied (WARN, not fixable): every depends_on
  target terminal (tasks done/cancel, workplans finished/archived), or
  decision_id resolved in the hub. A target or decision the hub cannot
  resolve is skipped and withholds the warning; decision_id resolves via
  a uuid, the repo's state_hub_decision_id writeback, or the derived id.
- C-41 task-wait-qualifier-drift (WARN, fixable): needs_human,
  blocking_reason, decision_id file -> hub via PATCH /tasks/{id}, only
  for blocks that carry at least one of the keys so hub-set flags on
  silent blocks are left alone. The same fields ride along on the C-10
  status PATCH for wait tasks and on the C-06/C-11 task creates. When
  needs_human is set and the block has no intervention_note, the
  blocking_reason stands in (TaskCreate/Update require the note).

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-28 22:39:18 +02:00
parent b9997d6fc7
commit 00c6e7a6ae
2 changed files with 580 additions and 6 deletions

View file

@ -42,6 +42,10 @@ Checks:
C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list 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-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-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-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) (finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood)
Usage: Usage:
@ -549,6 +553,70 @@ def _frontmatter_depends_on_tasks(meta: dict) -> list[str]:
return _dedupe_preserve(from_alias + from_depends_on) return _dedupe_preserve(from_alias + from_depends_on)
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
def _as_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"true", "yes", "1", "on"}
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."""
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
fields: dict[str, Any] = {
"needs_human": needs_human,
"blocking_reason": blocking_reason,
"decision_id": decision_id,
}
if needs_human:
fields["intervention_note"] = (
str(task.get("intervention_note") or "").strip() or blocking_reason
)
return fields
_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id")
def _dependency_target_satisfied(record_id: str, status: str) -> bool:
"""Rule 3: task targets are satisfied at done/cancel, workplan targets at
finished/archived."""
if _TASK_RECORD_ID_RE.search(record_id):
return normalise_task_status(status, default="") in {"done", "cancel"}
return normalise_workstream_status(status) in CLOSED_WORKSTREAM_STATUSES
def _repo_decision_hub_ids(repo_dir: Path) -> dict[str, str]:
"""Map decision record ids (e.g. CCR-2026-0004) to the hub uuid C-32 wrote
back into their YAML block."""
out: dict[str, str] = {}
for md in sorted(repo_dir.rglob("*.md")):
if any(part in _WORK_RECORD_SKIP_DIRS for part in md.parts):
continue
try:
text = md.read_text(errors="replace")
except OSError:
continue
if "state_hub_decision_id" not in text:
continue
for block in _YAML_ONLY_FENCE_RE.findall(text):
meta = _parse_yaml_block(block.strip())
if not isinstance(meta, dict) or meta.get("_parse_error"):
continue
rid = str(meta.get("id", "")).strip()
hub_id = str(meta.get("state_hub_decision_id", "")).strip().strip('"')
if rid and hub_id and hub_id not in ("~", "null", "None", "none"):
out[rid] = hub_id
return out
def _as_int_or_none(value: Any) -> int | None: def _as_int_or_none(value: Any) -> int | None:
if value in (None, "", "~", "null", "None", "none"): if value in (None, "", "~", "null", "None", "none"):
return None return None
@ -1421,6 +1489,46 @@ def check_repo(
snapshot_dependencies_by_workplan.setdefault( snapshot_dependencies_by_workplan.setdefault(
str(row["from_workplan_id"]), [] str(row["from_workplan_id"]), []
).append(row) ).append(row)
snapshot_tasks_by_id: dict[str, dict] = {
str(row["id"]): row
for rows in snapshot_tasks_by_workplan.values()
for row in rows
if row.get("id")
}
# Task-block depends_on targets (CUST-WP-0074) may live in other repos, so
# resolve them by the file mapping first and the ADR-007 derived uuid second.
dependency_target_cache: dict[str, tuple[str | None, str | None]] = {}
def _resolve_dependency_target(record_id: str) -> tuple[str | None, str | None]:
"""Return (hub uuid, hub status) for a workplan/task record id, or (None, None)."""
if record_id in dependency_target_cache:
return dependency_target_cache[record_id]
is_task = bool(_TASK_RECORD_ID_RE.search(record_id))
mapped = (task_file_id_to_sh_id if is_task else workplan_id_to_ws_id).get(record_id)
hub_id = mapped or _derived_work_record_uuid(record_id)
row = (snapshot_tasks_by_id if is_task else snapshot_workplans_by_id).get(hub_id)
if row is None:
row = _api_get(api_base, f"/{'tasks' if is_task else 'workplans'}/{hub_id}")
result: tuple[str | None, str | None] = (
(hub_id, str(row.get("status") or "")) if isinstance(row, dict) else (None, None)
)
dependency_target_cache[record_id] = result
return result
decision_hub_ids: dict[str, str] | None = None
def _resolve_decision(decision_id: str) -> dict | None:
nonlocal decision_hub_ids
if decision_hub_ids is None:
decision_hub_ids = _repo_decision_hub_ids(repo_dir)
hub_id = (
decision_id
if _UUID_RE.match(decision_id)
else decision_hub_ids.get(decision_id) or _derived_work_record_uuid(decision_id)
)
row = _api_get(api_base, f"/decisions/{hub_id}")
return row if isinstance(row, dict) else None
# Per-workplan checks # Per-workplan checks
for wp_file, meta, body in workplan_infos: for wp_file, meta, body in workplan_infos:
@ -1802,11 +1910,13 @@ def check_repo(
if from_id != ws_id: if from_id != ws_id:
continue continue
rel = dep.get("relationship_type") or "blocks" rel = dep.get("relationship_type") or "blocks"
# Frontmatter edges carry no from_task_id; task-block edges do.
from_task_id = dep.get("from_task_id") or None
to_workplan_id = dep.get("to_workstream_id") or dep.get("to_workplan_id") to_workplan_id = dep.get("to_workstream_id") or dep.get("to_workplan_id")
if to_workplan_id: if to_workplan_id:
existing_dep_keys.add(("workstream", to_workplan_id, rel)) existing_dep_keys.add((from_task_id, "workstream", to_workplan_id, rel))
if dep.get("to_task_id"): if dep.get("to_task_id"):
existing_dep_keys.add(("task", dep["to_task_id"], rel)) existing_dep_keys.add((from_task_id, "task", dep["to_task_id"], rel))
for target_wp_id in _frontmatter_depends_on_workplans(meta): for target_wp_id in _frontmatter_depends_on_workplans(meta):
target_ws_id = workplan_id_to_ws_id.get(target_wp_id) target_ws_id = workplan_id_to_ws_id.get(target_wp_id)
@ -1820,7 +1930,7 @@ def check_repo(
fixable=False, fixable=False,
) )
continue continue
dep_key = ("workstream", target_ws_id, "blocks") dep_key = (None, "workstream", target_ws_id, "blocks")
if dep_key not in existing_dep_keys: if dep_key not in existing_dep_keys:
report.add( report.add(
severity="WARN", severity="WARN",
@ -1849,7 +1959,7 @@ def check_repo(
fixable=False, fixable=False,
) )
continue continue
dep_key = ("task", target_sh_id, "starts_after") dep_key = (None, "task", target_sh_id, "starts_after")
if dep_key not in existing_dep_keys: if dep_key not in existing_dep_keys:
report.add( report.add(
severity="WARN", severity="WARN",
@ -1878,6 +1988,99 @@ def check_repo(
t_sh_id = "" t_sh_id = ""
t_status = normalise_task_status(task.get("status", "todo")) t_status = normalise_task_status(task.get("status", "todo"))
# CUST-WP-0074: task-block depends_on and wait qualifiers.
task_dep_targets = _frontmatter_depends_on_workplans(task) + _frontmatter_depends_on_tasks(task)
wait_fields = _task_wait_fields(task)
if t_status == "wait" and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES:
if not task_dep_targets and not wait_fields["needs_human"]:
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"
),
file_path=f"{fname}#{t_id}",
file_value=t_status,
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 = [
(target, _resolve_dependency_target(target)) for target in task_dep_targets
]
if resolved_targets and all(
hub_status is not None
and _dependency_target_satisfied(target, hub_status)
for target, (_hub_id, hub_status) in resolved_targets
):
report.add(
severity="WARN", check_id="C-40",
message=(
f"blocker satisfied, task still wait: '{t_id}' in workplan "
f"'{meta.get('id', fname)}' — every depends_on target is terminal "
f"({', '.join(task_dep_targets)})"
),
file_path=f"{fname}#{t_id}",
file_value=", ".join(task_dep_targets),
fixable=False,
)
# C-40: rule 4 — the tracked decision has been resolved.
if wait_fields["decision_id"]:
decision = _resolve_decision(wait_fields["decision_id"])
if decision is not None and str(decision.get("status") or "") == "resolved":
report.add(
severity="WARN", check_id="C-40",
message=(
f"blocker satisfied, task still wait: '{t_id}' in workplan "
f"'{meta.get('id', fname)}' — decision "
f"{wait_fields['decision_id']} is resolved"
),
file_path=f"{fname}#{t_id}",
file_value=wait_fields["decision_id"],
fixable=False,
)
if t_sh_id and task_dep_targets:
# C-20 for task blocks: the waiting task is the from side.
for target in task_dep_targets:
target_hub_id, _target_status = _resolve_dependency_target(target)
is_task_target = bool(_TASK_RECORD_ID_RE.search(target))
if not target_hub_id:
report.add(
severity="WARN",
check_id="C-20",
message=(
f"{'Task' if is_task_target else 'Workplan'} dependency target "
f"'{target}' of task '{t_id}' is not linked to State Hub"
),
file_path=f"{fname}#{t_id}",
file_value=target,
fixable=False,
)
continue
rel = "starts_after" if is_task_target else "blocks"
dep_key = (t_sh_id, "task" if is_task_target else "workstream", target_hub_id, rel)
if dep_key not in existing_dep_keys:
report.add(
severity="WARN",
check_id="C-20",
message=(
f"Missing DB dependency edge: task '{t_id}' "
f"{'starts after task' if is_task_target else 'depends on workplan'} {target}"
),
file_path=f"{fname}#{t_id}",
db_id=ws_id,
file_value=target,
fixable=True,
_fix_context={
"from_workstream_id": ws_id,
"from_task_id": t_sh_id,
"to_task_id" if is_task_target else "to_workstream_id": target_hub_id,
"relationship_type": rel,
"description": f"{t_id} depends_on {target}",
},
)
if t_sh_id: if t_sh_id:
file_task_sh_ids.add(t_sh_id) file_task_sh_ids.add(t_sh_id)
db_task = db_tasks_by_id.get(t_sh_id) db_task = db_tasks_by_id.get(t_sh_id)
@ -1946,7 +2149,12 @@ def check_repo(
file_value=t_status, file_value=t_status,
db_value=db_t_status, db_value=db_t_status,
fixable=True, fixable=True,
_fix_context={"task_id": t_sh_id, "status": t_status}, _fix_context={
"task_id": t_sh_id,
"status": t_status,
# A wait needs its qualifiers in the same PATCH.
"wait_fields": wait_fields if t_status == "wait" else {},
},
) )
file_description = task.get("description") file_description = task.get("description")
if isinstance(file_description, str): if isinstance(file_description, str):
@ -1999,6 +2207,29 @@ def check_repo(
fixable=True, fixable=True,
_fix_context={"task_id": t_sh_id, "flavor": file_task_flavor}, _fix_context={"task_id": t_sh_id, "flavor": file_task_flavor},
) )
# C-41: wait qualifiers — only where the file speaks, so hub-set
# flags on blocks that never carried the keys are left alone.
if any(key in task for key in _TASK_WAIT_FIELD_KEYS):
db_wait_fields = {
"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,
}
file_wait_fields = {key: wait_fields[key] for key in _TASK_WAIT_FIELD_KEYS}
if file_wait_fields != db_wait_fields:
report.add(
severity="WARN", check_id="C-41",
message=(
f"Task wait-qualifier drift '{t_id}': "
f"file={file_wait_fields!r} db={db_wait_fields!r} (file wins)"
),
file_path=f"{fname}#{t_id}",
db_id=t_sh_id,
file_value=json.dumps(file_wait_fields, sort_keys=True),
db_value=json.dumps(db_wait_fields, sort_keys=True),
fixable=True,
_fix_context={"task_id": t_sh_id, "wait_fields": wait_fields},
)
elif t_id: elif t_id:
# C-11: task exists in file but not linked to DB # C-11: task exists in file but not linked to DB
ws_status = ws.get("status", "") ws_status = ws.get("status", "")
@ -3290,6 +3521,7 @@ def fix_repo(
"priority": t_priority, "priority": t_priority,
"assignee": task.get("assignee") or None, "assignee": task.get("assignee") or None,
"flavor": task_flavor, "flavor": task_flavor,
**_task_wait_fields(task),
}) })
if t_data and "_error" not in t_data: if t_data and "_error" not in t_data:
t_db_id = t_data["id"] t_db_id = t_data["id"]
@ -3419,6 +3651,9 @@ def fix_repo(
"to_task_id": ctx.get("to_task_id"), "to_task_id": ctx.get("to_task_id"),
"relationship_type": ctx["relationship_type"], "relationship_type": ctx["relationship_type"],
} }
if ctx.get("from_task_id"):
body["from_task_id"] = ctx["from_task_id"]
body["description"] = ctx.get("description")
result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body) result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body)
if result is not None and "_error" not in result: if result is not None and "_error" not in result:
target = ctx.get("to_workstream_id") or ctx.get("to_task_id") target = ctx.get("to_workstream_id") or ctx.get("to_task_id")
@ -3435,7 +3670,8 @@ def fix_repo(
task_id = ctx["task_id"] task_id = ctx["task_id"]
status = ctx["status"] status = ctx["status"]
result = _api_patch(api_base, f"/tasks/{task_id}", result = _api_patch(api_base, f"/tasks/{task_id}",
{"status": status, "suppress_token_event": True}) {"status": status, "suppress_token_event": True,
**ctx.get("wait_fields", {})})
if result is not None and "_error" not in result: if result is not None and "_error" not in result:
report.fixes_applied.append( report.fixes_applied.append(
f"C-10 fixed: task {task_id[:8]}… status → {status!r}" f"C-10 fixed: task {task_id[:8]}… status → {status!r}"
@ -3475,6 +3711,7 @@ def fix_repo(
"status": t_status, "status": t_status,
"priority": t_priority, "priority": t_priority,
"assignee": task.get("assignee") or None, "assignee": task.get("assignee") or None,
**_task_wait_fields(task),
}) })
if t_data: if t_data:
t_db_id = t_data["id"] t_db_id = t_data["id"]
@ -3528,6 +3765,19 @@ def fix_repo(
f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}" f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}"
) )
elif issue.check_id == "C-41":
task_id = ctx["task_id"]
wait_fields = ctx["wait_fields"]
result = _api_patch(api_base, f"/tasks/{task_id}", wait_fields)
if result is not None and "_error" not in result:
report.fixes_applied.append(
f"C-41 fixed: task {task_id[:8]}… wait qualifiers synced"
)
elif result is not None:
report.fixes_applied.append(
f"C-41 FAILED: task {task_id[:8]}… wait qualifiers: {result['_error']}"
)
elif issue.check_id == "C-15": elif issue.check_id == "C-15":
# T03 — writeback: DB is ahead of file — patch file to match DB. # T03 — writeback: DB is ahead of file — patch file to match DB.
if no_writeback: if no_writeback:

View file

@ -2111,3 +2111,327 @@ class TestCoordinationHygieneHelpers:
result = _api_get("http://example", "/workstreams/ws-1") result = _api_get("http://example", "/workstreams/ws-1")
assert result == {"id": "ws-1"} assert result == {"id": "ws-1"}
assert attempts["count"] == 2 assert attempts["count"] == 2
class TestTaskWaitQualifiers:
"""CUST-WP-0074-T02: C-39 / C-40 / C-41 plus task-block depends_on in C-20."""
WORKPLAN_HEAD = (
"---\n"
"id: STATE-WP-0001\n"
"title: Waiting\n"
"domain: financials\n"
"repo: demo-repo\n"
"status: blocked\n"
"state_hub_workstream_id: \"wait-ws\"\n"
"---\n\n"
"## Wait on something\n\n"
)
def _make_repo(self, tmp_path, task_block: str, extra_files: dict[str, str] | None = None):
repo = tmp_path / "repo"
workplans = repo / "workplans"
workplans.mkdir(parents=True)
(workplans / "STATE-WP-0001-waiting.md").write_text(
self.WORKPLAN_HEAD + "```task\n" + task_block + "\n```\n\nBody text.\n",
encoding="utf-8",
)
for name, text in (extra_files or {}).items():
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return repo
def _fake_get(self, repo, *, db_task=None, targets=None, deps=None):
def fake_get(_api_base, path, params=None, **_kwargs):
if path == "/repos/demo-repo":
import socket
return {
"id": "repo-1",
"slug": "demo-repo",
"local_path": str(repo),
"host_paths": {socket.gethostname(): str(repo)},
"domain_slug": "financials",
}
if path == "/workplans/wait-ws":
return {
"id": "wait-ws", "repo_id": "repo-1", "slug": "state-wp-0001",
"title": "Waiting", "status": "blocked",
}
if path == "/tasks" and params and params.get("workplan_id") == "wait-ws":
return [db_task] if db_task else []
if path == "/workplans/wait-ws/dependencies":
return deps or []
if targets and path in targets:
return targets[path]
return []
return fake_get
def _install(self, monkeypatch, fake_get):
monkeypatch.setattr("consistency_check.load_classification_file", lambda _repo_dir: ({}, [], []))
monkeypatch.setattr("consistency_check._api_get", fake_get)
@staticmethod
def _issues(report, check_id):
return [issue for issue in report.issues if issue.check_id == check_id]
def test_wait_without_qualifier_warns_c39(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\"\nblocking_reason: \"someone else first\"",
)
self._install(monkeypatch, self._fake_get(repo, db_task={"id": "task-1", "status": "wait"}))
report = check_repo("http://unused", "demo-repo")
c39 = self._issues(report, "C-39")
assert len(c39) == 1
assert c39[0].severity == "WARN"
assert c39[0].fixable is False
assert "STATE-WP-0001-T01" in c39[0].message
assert "STATE-WP-0001" in c39[0].message
assert self._issues(report, "C-40") == []
def test_needs_human_or_depends_on_qualifies_the_wait(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\"\nneeds_human: true\n"
"blocking_reason: \"operator must provision creds\"",
)
self._install(monkeypatch, self._fake_get(repo))
assert self._issues(check_repo("http://unused", "demo-repo"), "C-39") == []
repo = self._make_repo(
tmp_path / "second",
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
"state_hub_task_id: \"task-1\"\ndepends_on: [FLEX-WP-0020]\n"
"blocking_reason: \"access-engine rename must land first\"",
)
self._install(monkeypatch, self._fake_get(repo))
report = check_repo("http://unused", "demo-repo")
assert self._issues(report, "C-39") == []
# Unresolvable target: C-20 reports it unlinked, C-40 stays silent.
assert self._issues(report, "C-40") == []
c20 = self._issues(report, "C-20")
assert len(c20) == 1 and c20[0].fixable is False
assert "FLEX-WP-0020" in c20[0].message
def test_task_block_depends_on_indexes_into_c20_with_from_task(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\"\n"
"depends_on:\n - FLEX-WP-0020\n - KEY-WP-0035-T02\n"
"blocking_reason: \"access-engine rename must land first\"",
)
flex_id = _derived_work_record_uuid("FLEX-WP-0020")
key_task_id = _derived_work_record_uuid("KEY-WP-0035-T02")
targets = {
f"/workplans/{flex_id}": {"id": flex_id, "status": "active"},
f"/tasks/{key_task_id}": {"id": key_task_id, "status": "progress"},
}
self._install(monkeypatch, self._fake_get(repo, targets=targets))
report = check_repo("http://unused", "demo-repo")
c20 = self._issues(report, "C-20")
assert len(c20) == 2
assert all(issue.fixable for issue in c20)
contexts = [issue._fix_context for issue in c20]
assert {
"from_workstream_id": "wait-ws",
"from_task_id": "task-1",
"to_workstream_id": flex_id,
"relationship_type": "blocks",
"description": "STATE-WP-0001-T01 depends_on FLEX-WP-0020",
} in contexts
assert {
"from_workstream_id": "wait-ws",
"from_task_id": "task-1",
"to_task_id": key_task_id,
"relationship_type": "starts_after",
"description": "STATE-WP-0001-T01 depends_on KEY-WP-0035-T02",
} in contexts
assert self._issues(report, "C-40") == []
# Existing rows with the task as the from side satisfy the check; a
# frontmatter row (no from_task_id) for the same target does not.
deps = [
{"id": "d1", "from_workplan_id": "wait-ws", "from_task_id": "task-1",
"to_workplan_id": flex_id, "to_task_id": None, "relationship_type": "blocks"},
{"id": "d2", "from_workplan_id": "wait-ws", "from_task_id": None,
"to_workplan_id": None, "to_task_id": key_task_id, "relationship_type": "starts_after"},
]
self._install(monkeypatch, self._fake_get(repo, targets=targets, deps=deps))
report = check_repo("http://unused", "demo-repo")
c20 = self._issues(report, "C-20")
assert len(c20) == 1
assert c20[0]._fix_context["to_task_id"] == key_task_id
def test_all_terminal_targets_warn_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\"\n"
"depends_on: [FLEX-WP-0020, KEY-WP-0035-T02]\n"
"blocking_reason: \"access-engine rename must land first\"",
)
flex_id = _derived_work_record_uuid("FLEX-WP-0020")
key_task_id = _derived_work_record_uuid("KEY-WP-0035-T02")
targets = {
f"/workplans/{flex_id}": {"id": flex_id, "status": "finished"},
f"/tasks/{key_task_id}": {"id": key_task_id, "status": "done"},
}
self._install(monkeypatch, self._fake_get(repo, targets=targets))
report = check_repo("http://unused", "demo-repo")
c40 = self._issues(report, "C-40")
assert len(c40) == 1
assert c40[0].severity == "WARN" and c40[0].fixable is False
assert c40[0].message.startswith("blocker satisfied, task still wait")
assert "STATE-WP-0001-T01" in c40[0].message
# One target still open → no C-40.
targets[f"/tasks/{key_task_id}"] = {"id": key_task_id, "status": "progress"}
self._install(monkeypatch, self._fake_get(repo, targets=targets))
assert self._issues(check_repo("http://unused", "demo-repo"), "C-40") == []
# One target the hub cannot resolve → skipped, no C-40 either.
del targets[f"/tasks/{key_task_id}"]
self._install(monkeypatch, self._fake_get(repo, targets=targets))
assert self._issues(check_repo("http://unused", "demo-repo"), "C-40") == []
def test_resolved_decision_warns_c40(self, tmp_path, monkeypatch):
decision_doc = (
"# Decisions\n\n```yaml\nid: CCR-2026-0004\nkind: decision\n"
"state_hub_decision_id: \"dec-1\"\n```\n"
)
repo = self._make_repo(
tmp_path,
"id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n"
"state_hub_task_id: \"task-1\"\nneeds_human: true\n"
"decision_id: CCR-2026-0004\n"
"blocking_reason: \"operator must provision creds\"",
extra_files={"docs/decisions.md": decision_doc},
)
self._install(
monkeypatch,
self._fake_get(repo, targets={"/decisions/dec-1": {"id": "dec-1", "status": "resolved"}}),
)
report = check_repo("http://unused", "demo-repo")
c40 = self._issues(report, "C-40")
assert len(c40) == 1 and c40[0].fixable is False
assert "CCR-2026-0004" in c40[0].message
self._install(
monkeypatch,
self._fake_get(repo, targets={"/decisions/dec-1": {"id": "dec-1", "status": "open"}}),
)
assert self._issues(check_repo("http://unused", "demo-repo"), "C-40") == []
# Decision unknown to the hub → skipped silently.
self._install(monkeypatch, self._fake_get(repo))
assert self._issues(check_repo("http://unused", "demo-repo"), "C-40") == []
def test_wait_qualifier_drift_c41_is_fixable(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\"\nneeds_human: true\n"
"decision_id: CCR-2026-0004\n"
"blocking_reason: \"operator must provision creds\"",
)
db_task = {
"id": "task-1", "status": "wait", "needs_human": False,
"blocking_reason": None, "decision_id": 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 == {
"task_id": "task-1",
"wait_fields": {
"needs_human": True,
"blocking_reason": "operator must provision creds",
"decision_id": "CCR-2026-0004",
"intervention_note": "operator must provision creds",
},
}
db_task.update(needs_human=True, blocking_reason="operator must provision creds",
decision_id="CCR-2026-0004")
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_ignores_blocks_that_do_not_carry_the_keys(self, tmp_path, monkeypatch):
repo = self._make_repo(
tmp_path,
"id: STATE-WP-0001-T01\nstatus: todo\npriority: high\nstate_hub_task_id: \"task-1\"",
)
db_task = {"id": "task-1", "status": "todo", "needs_human": True,
"blocking_reason": "set via API", "decision_id": None}
self._install(monkeypatch, self._fake_get(repo, db_task=db_task))
assert self._issues(check_repo("http://unused", "demo-repo"), "C-41") == []
def test_fix_repo_writes_from_task_edge_and_wait_fields(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\"\ndepends_on: [FLEX-WP-0020]\n"
"blocking_reason: \"access-engine rename must land first\"",
)
flex_id = _derived_work_record_uuid("FLEX-WP-0020")
db_task = {"id": "task-1", "status": "todo", "needs_human": False,
"blocking_reason": None, "decision_id": None}
self._install(
monkeypatch,
self._fake_get(repo, db_task=db_task,
targets={f"/workplans/{flex_id}": {"id": flex_id, "status": "active"}}),
)
posts, patches = [], []
monkeypatch.setattr(
"consistency_check._api_post",
lambda _api_base, path, body: posts.append((path, body)) or {"id": "dep-new"},
)
monkeypatch.setattr(
"consistency_check._api_patch",
lambda _api_base, path, body: patches.append((path, body)) or {"ok": True},
)
monkeypatch.setattr("consistency_check._detect_behind_remote", lambda _repo_path: False)
monkeypatch.setattr("consistency_check._detect_ahead_of_remote", lambda _repo_path: 0)
monkeypatch.setattr("consistency_check._git_commit_writeback", lambda *args, **kwargs: True)
monkeypatch.setattr("consistency_check._write_custodian_brief", lambda *args, **kwargs: False)
monkeypatch.setattr("consistency_check._git_push", lambda _repo_path: (True, "pushed"))
report = fix_repo("http://unused", "demo-repo")
assert (
"/workplans/wait-ws/dependencies",
{
"to_workstream_id": flex_id,
"to_task_id": None,
"relationship_type": "blocks",
"from_task_id": "task-1",
"description": "STATE-WP-0001-T01 depends_on FLEX-WP-0020",
},
) in posts
# C-10 carries the wait qualifiers in the same PATCH; C-41 syncs them too.
wait_fields = {
"needs_human": False,
"blocking_reason": "access-engine rename must land first",
"decision_id": None,
}
assert ("/tasks/task-1", {"status": "wait", "suppress_token_event": True, **wait_fields}) in patches
assert ("/tasks/task-1", wait_fields) in patches
assert any("C-20 fixed" in fix for fix in report.fixes_applied)
assert any("C-41 fixed" in fix for fix in report.fixes_applied)