fix(consistency_check): heading titles + workstream-aware task guards
- parse_task_blocks() now injects the nearest preceding ### heading text as `title` — tasks no longer stored with bare IDs as their title - C-11 fix skips creating tasks when workstream is completed/archived (prevents duplicate task creation on repeated fix-consistency runs) - C-12 is now fixable: auto-cancels open orphan DB tasks when the backing workstream is finished (completed/archived) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,7 @@ except ImportError:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
|
||||
_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE)
|
||||
VALID_WP_STATUSES = {"active", "completed", "archived"}
|
||||
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
||||
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||
@@ -148,8 +149,17 @@ def parse_frontmatter(text: str) -> tuple[dict, str]:
|
||||
|
||||
|
||||
def parse_task_blocks(body: str) -> list[dict]:
|
||||
"""Extract all ```task ... ``` YAML blocks from a workplan body."""
|
||||
return [_parse_yaml_block(m.group(1).strip()) for m in _TASK_BLOCK_RE.finditer(body)]
|
||||
"""Extract task blocks, injecting title from the nearest preceding ### heading."""
|
||||
headings = [(m.start(), m.group(1).strip()) for m in _HEADING_RE.finditer(body)]
|
||||
results = []
|
||||
for m in _TASK_BLOCK_RE.finditer(body):
|
||||
task = _parse_yaml_block(m.group(1).strip())
|
||||
if "title" not in task:
|
||||
prev = [(pos, text) for pos, text in headings if pos < m.start()]
|
||||
if prev:
|
||||
task["title"] = prev[-1][1]
|
||||
results.append(task)
|
||||
return results
|
||||
|
||||
|
||||
def get_tasks_from_workplan(meta: dict, body: str) -> list[dict]:
|
||||
@@ -524,6 +534,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
)
|
||||
elif t_id:
|
||||
# C-11: task exists in file but not linked to DB
|
||||
ws_status = ws.get("status", "")
|
||||
report.add(
|
||||
severity="WARN", check_id="C-11",
|
||||
message=f"Task '{t_id}' has no state_hub_task_id",
|
||||
@@ -531,6 +542,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
fixable=True,
|
||||
_fix_context={
|
||||
"ws_id": ws_id,
|
||||
"ws_status": ws_status,
|
||||
"task": task,
|
||||
"wp_file": str(wp_file),
|
||||
"meta": meta,
|
||||
@@ -540,17 +552,27 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
|
||||
# C-12: DB tasks with no file backing
|
||||
if isinstance(db_tasks, list):
|
||||
ws_status = ws.get("status", "")
|
||||
ws_finished = ws_status in ("completed", "archived")
|
||||
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")
|
||||
# Auto-cancel fixable when workstream is finished and task is open
|
||||
fixable = ws_finished and open_task
|
||||
report.add(
|
||||
severity="WARN", check_id="C-12",
|
||||
message=(
|
||||
f"DB task '{db_t.get('title', '')}' "
|
||||
f"(id={db_t['id'][:8]}…, status={db_t.get('status', '')}) "
|
||||
f"(id={db_t['id'][:8]}…, status={db_t_status}) "
|
||||
f"in workstream '{ws.get('slug')}' has no file backing"
|
||||
),
|
||||
db_id=db_t["id"],
|
||||
fixable=False,
|
||||
fixable=fixable,
|
||||
_fix_context={
|
||||
"task_id": db_t["id"],
|
||||
"ws_finished": ws_finished,
|
||||
},
|
||||
)
|
||||
|
||||
# C-13: all DB tasks done but workstream still active — worker forgot to close
|
||||
@@ -810,34 +832,51 @@ def fix_repo(api_base: str, repo_slug: str, repo_path_override: str | None = Non
|
||||
|
||||
elif issue.check_id == "C-11":
|
||||
ws_id = ctx["ws_id"]
|
||||
ws_status = ctx.get("ws_status", "")
|
||||
task = ctx["task"]
|
||||
wp_file = Path(ctx["wp_file"])
|
||||
meta = ctx["meta"]
|
||||
body = ctx.get("body", "")
|
||||
t_id = str(task.get("id", "")).strip()
|
||||
t_status = str(task.get("status", "todo")).strip()
|
||||
if t_status not in VALID_TASK_STATUSES:
|
||||
t_status = "todo"
|
||||
t_priority = str(task.get("priority", "medium")).strip()
|
||||
if t_priority not in VALID_TASK_PRIORITIES:
|
||||
t_priority = "medium"
|
||||
t_data = _api_post(api_base, "/tasks", {
|
||||
"workstream_id": ws_id,
|
||||
"title": str(task.get("title", t_id)).strip() or t_id,
|
||||
"status": t_status,
|
||||
"priority": t_priority,
|
||||
"assignee": task.get("assignee") or None,
|
||||
})
|
||||
if t_data:
|
||||
t_db_id = t_data["id"]
|
||||
injected = _inject_task_id_into_block(
|
||||
wp_file, "state_hub_task_id", t_db_id, t_id
|
||||
)
|
||||
if not injected:
|
||||
_inject_task_id_frontmatter_list(wp_file, t_db_id, t_id)
|
||||
# Skip creating tasks for finished workstreams — the workstream is
|
||||
# done/archived so unlinked tasks are stale file artefacts, not gaps.
|
||||
if ws_status in ("completed", "archived"):
|
||||
report.fixes_applied.append(
|
||||
f"C-11 fixed: task '{t_id}' → {t_db_id[:8]}…"
|
||||
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_priority = str(task.get("priority", "medium")).strip()
|
||||
if t_priority not in VALID_TASK_PRIORITIES:
|
||||
t_priority = "medium"
|
||||
t_data = _api_post(api_base, "/tasks", {
|
||||
"workstream_id": ws_id,
|
||||
"title": str(task.get("title", t_id)).strip() or t_id,
|
||||
"status": t_status,
|
||||
"priority": t_priority,
|
||||
"assignee": task.get("assignee") or None,
|
||||
})
|
||||
if t_data:
|
||||
t_db_id = t_data["id"]
|
||||
injected = _inject_task_id_into_block(
|
||||
wp_file, "state_hub_task_id", t_db_id, t_id
|
||||
)
|
||||
if not injected:
|
||||
_inject_task_id_frontmatter_list(wp_file, t_db_id, t_id)
|
||||
report.fixes_applied.append(
|
||||
f"C-11 fixed: task '{t_id}' → {t_db_id[:8]}…"
|
||||
)
|
||||
|
||||
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"})
|
||||
if result is not None:
|
||||
report.fixes_applied.append(
|
||||
f"C-12 fixed: orphan task {task_id[:8]}… cancelled (workstream finished)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
report.fixes_applied.append(f"{issue.check_id} ERROR: {e}")
|
||||
|
||||
Reference in New Issue
Block a user