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:
parent
b9997d6fc7
commit
00c6e7a6ae
2 changed files with 580 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue