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

@@ -7,7 +7,7 @@ Run via make: make cleanup-stale
Cron example: 0 3 * * * cd ~/state-hub && .venv/bin/python scripts/cleanup_stale_tasks.py
Exit codes:
0 — ran successfully (zero or more tasks cancelled)
0 — ran successfully (zero or more tasks canceled)
1 — API unreachable or unexpected error
"""
@@ -23,6 +23,7 @@ from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
from api.task_status import OPEN_TASK_STATUSES, normalize_task_status
try:
from api.events import EventEnvelope, publish_event, shutdown_publisher
@@ -32,7 +33,7 @@ except Exception: # pragma: no cover — event publishing is optional
shutdown_publisher = None # type: ignore[assignment]
API = "http://127.0.0.1:8000"
STALE_STATUSES = {"todo", "in_progress", "blocked"}
STALE_STATUSES = set(OPEN_TASK_STATUSES)
CLOSED_WS_STATUS = set(CLOSED_WORKSTREAM_STATUSES)
@@ -91,7 +92,7 @@ def main() -> int:
stale = [
t for t in tasks
if t["status"] in STALE_STATUSES
if normalize_task_status(t["status"]) in STALE_STATUSES
and t["workstream_id"] in closed_ws
]
@@ -115,10 +116,10 @@ def main() -> int:
try:
patch(
f"/tasks/{t['id']}/",
{"status": "cancelled", "blocking_reason": reason},
{"status": "cancel", "blocking_reason": reason},
)
cancelled.append(t)
print(f" cancelled [{t['priority']:8}] {t['title'][:70]}")
print(f" canceled [{t['priority']:8}] {t['title'][:70]}")
if EventEnvelope is not None:
subject = "org.statehub.task.stale"
nats_events.append((
@@ -155,11 +156,11 @@ def main() -> int:
by_ws.setdefault(closed_ws[t["workstream_id"]]["title"], []).append(t["title"])
summary = (
f"Stale-task cleanup: cancelled {len(cancelled)} task(s) "
f"Stale-task cleanup: canceled {len(cancelled)} task(s) "
f"across {len(by_ws)} finished workstream(s)"
)
detail = {
"cancelled_count": len(cancelled),
"canceled_count": len(cancelled),
"by_workstream": {ws: titles for ws, titles in by_ws.items()},
"error_count": len(errors),
}
@@ -173,7 +174,7 @@ def main() -> int:
print(f"[cleanup-stale] Completed with {len(errors)} error(s).")
return 1
print(f"[cleanup-stale] Done. {len(cancelled)} task(s) cancelled.")
print(f"[cleanup-stale] Done. {len(cancelled)} task(s) canceled.")
return 0

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

View File

@@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
# values: todo | in_progress | done | blocked
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
@@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent={REPO_SLUG}&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check blocked tasks: `GET /tasks/?needs_human=true`
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
@@ -146,7 +146,7 @@ derived health labels, not frontmatter statuses.
` ` `task
id: {WP_PREFIX}-NNNN-T01
status: todo | in_progress | done | blocked
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
@@ -154,7 +154,7 @@ state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
Task description text.
```
Status progression: `todo` → `in_progress` → `done` (or `blocked`)
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above

View File

@@ -39,7 +39,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`todo`/`in_progress` tasks.
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**

View File

@@ -49,6 +49,7 @@ from api.workplan_status import ( # noqa: E402
SUPPORTED_WORKSTREAM_STATUSES,
normalize_workstream_status,
)
from api.task_status import CANONICAL_TASK_STATUSES, normalize_task_status # noqa: E402
try:
import yaml as _yaml
@@ -70,7 +71,7 @@ except ImportError:
REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"}
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"}
_WP_ID_RE = re.compile(r"^(?:[A-Z]+-WP-\d+|ADHOC-\d{4}-\d{2}-\d{2})$")
@@ -264,10 +265,12 @@ def _check_workplan_file(wp_file: Path, report: Report) -> dict | None:
t_status = str(task.get("status", ""))
if not t_status:
report.add(Level.FAIL, "task-status", "Missing 'status' field", tref)
elif t_status not in VALID_TASK_STATUSES:
report.add(Level.FAIL, "task-status-value",
f"status {t_status!r} not in {sorted(VALID_TASK_STATUSES)}", tref)
else:
try:
normalize_task_status(t_status)
except ValueError:
report.add(Level.FAIL, "task-status-value",
f"status {t_status!r} not in {sorted(VALID_TASK_STATUSES)}", tref)
t_prio = str(task.get("priority", ""))
if not t_prio:
report.add(Level.WARN, "task-priority", "Missing 'priority' field", tref)