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

@ -2111,3 +2111,327 @@ class TestCoordinationHygieneHelpers:
result = _api_get("http://example", "/workstreams/ws-1")
assert result == {"id": "ws-1"}
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)