generated from coulomb/repo-seed
feat(tasks): adopt canonical task statuses
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user