feat(tasks): adopt canonical task statuses

This commit is contained in:
2026-05-26 01:32:50 +02:00
parent da5aee6e38
commit 38835e9e79
61 changed files with 692 additions and 342 deletions

View File

@@ -25,7 +25,7 @@ Checks:
C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB
C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph
C-22 task-description-drift WARN Yes Task description/content differs between file and DB
C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is in progress or blocked
C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait
Usage:
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
@@ -67,6 +67,13 @@ from api.workplan_status import ( # noqa: E402
ready_review_status,
)
from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402
from api.task_status import ( # noqa: E402
CANONICAL_TASK_STATUSES,
OPEN_TASK_STATUSES,
TASK_STATUS_ORDER,
TERMINAL_TASK_STATUSES,
normalize_task_status,
)
try:
import yaml as _yaml
@@ -90,7 +97,7 @@ _HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE)
_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES)
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
VALID_TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300"))
@@ -99,14 +106,7 @@ DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_
FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES)
# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15).
# blocked and in_progress share rank 1 — both are "in flight".
STATUS_ORDER: dict[str, int] = {
"todo": 0,
"in_progress": 1,
"blocked": 1,
"done": 2,
"cancelled": 2,
}
STATUS_ORDER: dict[str, int] = dict(TASK_STATUS_ORDER)
def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str:
@@ -114,6 +114,13 @@ def normalise_workstream_status(status: str, *, has_started: bool | None = None)
return _normalize_workstream_status(status, has_started=has_started)
def normalise_task_status(status: Any, *, default: str = "todo") -> str:
try:
return normalize_task_status(status, default=default)
except ValueError:
return default
def canonical_workplan_filename(path: Path) -> str:
"""Return the workplan filename without an archive completion-date prefix."""
return _ARCHIVED_WP_RE.sub(r"\1", path.name)
@@ -193,7 +200,7 @@ RENORMALIZATION_RULES: tuple[RenormalizationRule, ...] = (
invariant="Planning-state workplans cannot contain active task work.",
detection=(
"Workplan status is proposed, ready, or backlog while a linked "
"task is in_progress or blocked."
"task is progress or wait."
),
repair="Patch the DB workstream and workplan frontmatter to status=active.",
test_anchor="tests/test_consistency_check.py::TestLifecycleRenormalization",
@@ -997,7 +1004,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
t_sh_id = "" if _raw_sh is None else str(_raw_sh).strip().strip('"')
if t_sh_id in ("~", "null", "None", "none"):
t_sh_id = ""
t_status = str(task.get("status", "")).strip()
t_status = normalise_task_status(task.get("status", "todo"))
if t_sh_id:
file_task_sh_ids.add(t_sh_id)
@@ -1012,7 +1019,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
)
continue
# C-10 / C-15: task status drift
db_t_status = db_task.get("status", "")
db_t_status = normalise_task_status(db_task.get("status", "todo"))
if t_status and db_t_status and t_status != db_t_status:
db_rank = STATUS_ORDER.get(db_t_status, 0)
file_rank = STATUS_ORDER.get(t_status, 0)
@@ -1099,7 +1106,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
for db_t in db_tasks:
if db_t["id"] not in file_task_sh_ids:
db_t_status = db_t.get("status", "")
open_task = db_t_status not in ("done", "cancelled")
open_task = db_t_status not in TERMINAL_TASK_STATUSES
# Auto-cancel fixable when workstream is finished and task is open
fixable = ws_finished and open_task
report.add(
@@ -1122,14 +1129,14 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks:
non_terminal = [
t for t in db_tasks
if t.get("status") not in ("done", "cancelled")
if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES
]
if not non_terminal:
report.add(
severity="WARN", check_id="C-13",
message=(
f"All {len(db_tasks)} DB tasks for '{ws.get('slug')}' are "
f"done/cancelled but workstream status is still 'active'"
f"done/cancel but workstream status is still 'active'"
f"worker likely forgot update_workstream_status()"
),
file_path=fname,
@@ -1358,8 +1365,8 @@ def _git_commit_writeback(
# ---------------------------------------------------------------------------
_BRIEF_HEADER = "<!-- custodian-brief: generated by fix-consistency — do not edit manually -->"
_TASK_STATUS_ICON = {"done": "", "cancelled": "", "in_progress": "", "blocked": "!", "todo": "·"}
_OPEN_STATUSES = {"todo", "in_progress", "blocked"}
_TASK_STATUS_ICON = {"done": "", "cancel": "", "progress": "", "wait": "!", "todo": "·"}
_OPEN_STATUSES = set(OPEN_TASK_STATUSES)
def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> bool:
@@ -1429,14 +1436,24 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
if not isinstance(tasks, list):
tasks = []
done = sum(1 for t in tasks if t.get("status") in ("done", "cancelled"))
done = sum(
1 for t in tasks
if normalise_task_status(t.get("status", "todo")) in TERMINAL_TASK_STATUSES
)
total = len(tasks)
pct = f"{done}/{total}" if total else "no tasks"
open_tasks = [t for t in tasks if t.get("status") in _OPEN_STATUSES]
# Show blocked first, then in_progress, then todo (cap at 5)
priority_order = {"blocked": 0, "in_progress": 1, "todo": 2}
open_tasks.sort(key=lambda t: priority_order.get(t.get("status", "todo"), 9))
open_tasks = [
t for t in tasks
if normalise_task_status(t.get("status", "todo")) in _OPEN_STATUSES
]
# Show wait first, then progress, then todo (cap at 5).
priority_order = {"wait": 0, "progress": 1, "todo": 2}
open_tasks.sort(
key=lambda t: priority_order.get(
normalise_task_status(t.get("status", "todo")), 9
)
)
lines += [
"",
@@ -1448,14 +1465,14 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
lines.append("")
lines.append("**Open tasks:**")
for t in open_tasks[:7]:
icon = _TASK_STATUS_ICON.get(t.get("status", "todo"), "·")
status = normalise_task_status(t.get("status", "todo"))
icon = _TASK_STATUS_ICON.get(status, "·")
title = t.get("title", t["id"])
tid = t["id"]
status = t.get("status", "")
blocker = t.get("blocking_reason", "")
task_line = f"- {icon} {title} `{tid[:8]}`"
if status == "blocked" and blocker:
task_line += f"\n *(blocked: {blocker})*"
if status == "wait" and blocker:
task_line += f"\n *(wait: {blocker})*"
lines.append(task_line)
if len(open_tasks) > 7:
lines.append(f"- … and {len(open_tasks) - 7} more open tasks")
@@ -1685,9 +1702,7 @@ def fix_repo(
t_id = str(task.get("id", "")).strip()
if not t_id:
continue
t_status = str(task.get("status", "todo")).strip()
if t_status not in VALID_TASK_STATUSES:
t_status = "todo"
t_status = normalise_task_status(task.get("status", "todo"))
t_priority = str(task.get("priority", "medium")).strip()
if t_priority not in VALID_TASK_PRIORITIES:
t_priority = "medium"
@@ -1767,9 +1782,7 @@ def fix_repo(
f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created"
)
else:
t_status = str(task.get("status", "todo")).strip()
if t_status not in VALID_TASK_STATUSES:
t_status = "todo"
t_status = normalise_task_status(task.get("status", "todo"))
t_priority = str(task.get("priority", "medium")).strip()
if t_priority not in VALID_TASK_PRIORITIES:
t_priority = "medium"
@@ -1795,10 +1808,10 @@ def fix_repo(
elif issue.check_id == "C-12":
task_id = ctx["task_id"]
if ctx.get("ws_finished"):
result = _api_patch(api_base, f"/tasks/{task_id}", {"status": "cancelled"})
result = _api_patch(api_base, f"/tasks/{task_id}", {"status": "cancel"})
if result is not None:
report.fixes_applied.append(
f"C-12 fixed: orphan task {task_id[:8]}… cancelled (workstream finished)"
f"C-12 fixed: orphan task {task_id[:8]}… canceled (workstream finished)"
)
elif issue.check_id == "C-22":
@@ -2013,7 +2026,7 @@ def archive_closed_workplans(
tasks = get_tasks_from_workplan(meta, body)
open_tasks = [
t for t in tasks
if str(t.get("status", "")).strip() not in ("done", "cancelled")
if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES
]
if open_tasks:
continue