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