From 90c9f8e7a7f0b7237567f62861f052dbf9f565d5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 23 May 2026 16:31:28 +0200 Subject: [PATCH] Normalize workplan IDs and activate parents on task start --- README.md | 2 +- SCOPE.md | 2 +- api/routers/tasks.py | 21 ++++++-- api/services/lifecycle.py | 50 +++++++++++++++++++ docs/workplan-convention.md | 4 +- tests/test_routers_core.py | 36 ++++++++++++- ...P-0011-state-hub-threephoenix-migration.md | 18 +++---- .../CUST-WP-0038-state-hub-threephoenix-ha.md | 16 +++--- ...STATE-WP-0046-workplan-id-normalization.md | 47 ++++++++++++++--- ...ifecycle-assertions-and-renormalization.md | 37 ++++++++++++-- 10 files changed, 195 insertions(+), 38 deletions(-) create mode 100644 api/services/lifecycle.py diff --git a/README.md b/README.md index d108376..15e9069 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Current state: New State Hub-local workplans should use the prefix: ```text -SHUB-WP-0001 +STATE-WP-0001 ``` Legacy Custodian-hosted State Hub plans, such as `CUST-WP-0042`, may retain diff --git a/SCOPE.md b/SCOPE.md index 3540ccf..fb3e22a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -40,7 +40,7 @@ embedded path in `the-custodian` is now a pointer only. ## Workplan Convention -New State Hub-local workplans use `SHUB-WP-####`. +New State Hub-local workplans use `STATE-WP-####`. Migrated legacy State Hub workplans may temporarily retain `CUST-WP-####` identifiers when preserving existing State Hub workstream and task IDs is the diff --git a/api/routers/tasks.py b/api/routers/tasks.py index 1551164..cc96062 100644 --- a/api/routers/tasks.py +++ b/api/routers/tasks.py @@ -10,6 +10,7 @@ from api.models.task import Task, TaskStatus from api.models.token_event import TokenEvent from api.models.workstream import Workstream from api.schemas.task import TaskCreate, TaskRead, TaskUpdate +from api.services.lifecycle import activate_parent_for_task_start, status_value router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -49,6 +50,13 @@ async def create_task( ) -> Task: task = Task(**body.model_dump()) session.add(task) + if status_value(task.status) == "in_progress": + ws = await session.get(Workstream, task.workstream_id) + activate_parent_for_task_start( + previous_task_status="todo", + new_task_status=task.status, + parent_workstream=ws, + ) await session.commit() await session.refresh(task) return task @@ -75,7 +83,7 @@ async def update_task( if task is None: raise HTTPException(status_code=404, detail="Task not found") - previous_status = task.status.value + previous_status = status_value(task.status) # Separate token fields from task fields token_field_names = { @@ -92,15 +100,22 @@ async def update_task( update_data = body.model_dump(exclude_unset=True) token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_field_names} suppress_token_event = bool(token_data.pop("suppress_token_event", False)) + status_update = update_data.get("status") + new_status = status_value(status_update) if status_update is not None else None for field, value in update_data.items(): setattr(task, field, value) + if new_status is not None: + ws = await session.get(Workstream, task.workstream_id) + activate_parent_for_task_start( + previous_task_status=previous_status, + new_task_status=new_status, + parent_workstream=ws, + ) await session.commit() await session.refresh(task) # Token event — three-tier logic, only for an intentional transition to done. - status_update = update_data.get("status") - new_status = status_update.value if hasattr(status_update, "value") else status_update if ( new_status == "done" and previous_status != "done" diff --git a/api/services/lifecycle.py b/api/services/lifecycle.py new file mode 100644 index 0000000..71c0809 --- /dev/null +++ b/api/services/lifecycle.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + +from api.workplan_status import normalize_workstream_status + + +TASK_STARTED_STATUS = "in_progress" +TASK_NOT_STARTED_STATUS = "todo" +PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"} + + +def status_value(status: Any) -> str: + if hasattr(status, "value"): + status = status.value + return str(status or "").strip().lower() + + +def should_activate_parent_for_task_start( + *, + previous_task_status: Any, + new_task_status: Any, + parent_workstream_status: Any, +) -> bool: + """Return whether a task start should move its parent to active.""" + return ( + status_value(previous_task_status) == TASK_NOT_STARTED_STATUS + and status_value(new_task_status) == TASK_STARTED_STATUS + and normalize_workstream_status(parent_workstream_status) + in PARENT_ACTIVATION_STATUSES + ) + + +def activate_parent_for_task_start( + *, + previous_task_status: Any, + new_task_status: Any, + parent_workstream: Any, +) -> bool: + """Activate a planning-state parent workstream when real task work starts.""" + if parent_workstream is None: + return False + if not should_activate_parent_for_task_start( + previous_task_status=previous_task_status, + new_task_status=new_task_status, + parent_workstream_status=getattr(parent_workstream, "status", None), + ): + return False + parent_workstream.status = "active" + return True diff --git a/docs/workplan-convention.md b/docs/workplan-convention.md index 1cf34ac..3067cc5 100644 --- a/docs/workplan-convention.md +++ b/docs/workplan-convention.md @@ -3,13 +3,13 @@ New workplans in this repository use: ```text -SHUB-WP-0001-short-title.md +STATE-WP-0001-short-title.md ``` Workplan frontmatter should include: ```yaml -id: SHUB-WP-0001 +id: STATE-WP-0001 type: workplan title: "Short Title" domain: custodian diff --git a/tests/test_routers_core.py b/tests/test_routers_core.py index 4390670..3750291 100644 --- a/tests/test_routers_core.py +++ b/tests/test_routers_core.py @@ -28,9 +28,9 @@ async def _create_topic(client, domain_slug="testdomain", slug="testtopic", titl return r.json() -async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS"): +async def _create_workstream(client, topic_id, slug="test-ws", title="Test WS", status="active"): r = await client.post("/workstreams/", json={ - "topic_id": topic_id, "slug": slug, "title": title, + "topic_id": topic_id, "slug": slug, "title": title, "status": status, }) assert r.status_code == 201, r.text return r.json() @@ -213,6 +213,38 @@ class TestTasks: assert "High prio" in titles assert "Low prio" not in titles + @pytest.mark.parametrize("initial_status", ["proposed", "ready", "backlog"]) + async def test_task_start_activates_planning_workstream(self, client, initial_status): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream( + client, + topic["id"], + slug=f"{initial_status}-ws", + status=initial_status, + ) + task = await _create_task(client, ws["id"]) + + r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"}) + assert r.status_code == 200 + + r = await client.get(f"/workstreams/{ws['id']}") + assert r.status_code == 200 + assert r.json()["status"] == "active" + + async def test_task_start_does_not_unblock_blocked_workstream(self, client): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream(client, topic["id"], slug="blocked-ws", status="blocked") + task = await _create_task(client, ws["id"]) + + r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"}) + assert r.status_code == 200 + + r = await client.get(f"/workstreams/{ws['id']}") + assert r.status_code == 200 + assert r.json()["status"] == "blocked" + # --------------------------------------------------------------------------- # Decision tests diff --git a/workplans/CUST-WP-0011-state-hub-threephoenix-migration.md b/workplans/CUST-WP-0011-state-hub-threephoenix-migration.md index a33e267..8c1f79f 100644 --- a/workplans/CUST-WP-0011-state-hub-threephoenix-migration.md +++ b/workplans/CUST-WP-0011-state-hub-threephoenix-migration.md @@ -104,7 +104,7 @@ Resolve these before T04/T05 can become live migration work: ### T01 — Drill WSL2 State Hub backup restore ```task -id: T01 +id: CUST-WP-0011-T01 status: done priority: high state_hub_task_id: "b0caf112-dc1d-43a8-9f27-d627dd4aa2bf" @@ -136,7 +136,7 @@ workstreams, 989 tasks, 1423 progress events, and 208 token events. ### T02 — Align with Railiance deployment plan ```task -id: T02 +id: CUST-WP-0011-T02 status: done priority: high state_hub_task_id: "24887dd9-7d50-4cc4-add7-bffa1454b80c" @@ -167,7 +167,7 @@ deferred to `CUST-WP-0038`. ### T03 — Build and publish State Hub container image ```task -id: T03 +id: CUST-WP-0011-T03 status: in_progress priority: high state_hub_task_id: "79908ade-3e38-451b-a403-2361a16a3f3a" @@ -216,7 +216,7 @@ pull the image from railiance01. ### T04 — Define State Hub database and app manifests ```task -id: T04 +id: CUST-WP-0011-T04 status: todo priority: high state_hub_task_id: "a7baf2eb-abd7-4aa3-b2cb-a5370ac09844" @@ -238,7 +238,7 @@ boundaries are documented. ### T05 — Deploy empty State Hub and run migrations on railiance01 ```task -id: T05 +id: CUST-WP-0011-T05 status: todo priority: high state_hub_task_id: "a307dd46-a8e2-49df-b016-c187759ebcf1" @@ -261,7 +261,7 @@ Checks: ### T06 — Restore WSL2 data copy into cluster and compare ```task -id: T06 +id: CUST-WP-0011-T06 status: todo priority: high state_hub_task_id: "03753b88-824c-4448-97b2-f7315d145060" @@ -286,7 +286,7 @@ writer. ### T07 — Cut over private access to cluster State Hub ```task -id: T07 +id: CUST-WP-0011-T07 status: todo priority: medium state_hub_task_id: "ff1de25e-c301-4b86-9420-84dfe72e565e" @@ -312,7 +312,7 @@ cluster State Hub, and WSL2 is no longer receiving normal writes. ### T08 — Stabilise with WSL2 retained as fallback ```task -id: T08 +id: CUST-WP-0011-T08 status: todo priority: medium state_hub_task_id: "e06a59a0-5310-4c1c-9ba5-7cfaadda62e2" @@ -337,7 +337,7 @@ unresolved operational defects. ### T09 — Document operating model and defer final WSL2 retirement ```task -id: T09 +id: CUST-WP-0011-T09 status: todo priority: low state_hub_task_id: "d75a2d49-f3b1-4bdd-b9e1-a1c6a9744681" diff --git a/workplans/CUST-WP-0038-state-hub-threephoenix-ha.md b/workplans/CUST-WP-0038-state-hub-threephoenix-ha.md index f0260fb..ff40f57 100644 --- a/workplans/CUST-WP-0038-state-hub-threephoenix-ha.md +++ b/workplans/CUST-WP-0038-state-hub-threephoenix-ha.md @@ -64,7 +64,7 @@ keeps the ultimate target visible and reviewable. ### T01 — Confirm ThreePhoenix cluster readiness ```task -id: T01 +id: CUST-WP-0038-T01 status: todo priority: high state_hub_task_id: "aa1bf291-3b6c-4940-a4f5-7680b0349110" @@ -85,7 +85,7 @@ NotReady or operationally unknown. ### T02 — Establish replicated storage/database strategy ```task -id: T02 +id: CUST-WP-0038-T02 status: todo priority: high state_hub_task_id: "5575f244-5cef-47aa-a168-24027cd08140" @@ -107,7 +107,7 @@ production data movement. ### T03 — Implement HA State Hub database ```task -id: T03 +id: CUST-WP-0038-T03 status: todo priority: high state_hub_task_id: "5330fcc3-684b-49f6-8d28-ea8c929733d6" @@ -130,7 +130,7 @@ namespace. ### T04 — Add State Hub API high-availability behavior ```task -id: T04 +id: CUST-WP-0038-T04 status: todo priority: medium state_hub_task_id: "64175ed0-af36-47ea-9401-74c4b15ffe24" @@ -150,7 +150,7 @@ Run State Hub API with the right availability posture for its workload: ### T05 — Drill database failover ```task -id: T05 +id: CUST-WP-0038-T05 status: todo priority: high state_hub_task_id: "73c5008a-380e-42bf-ad57-1c9d0bda3a86" @@ -172,7 +172,7 @@ Checks: ### T06 — Drill backup restore to isolated namespace ```task -id: T06 +id: CUST-WP-0038-T06 status: todo priority: high state_hub_task_id: "4e5b97ff-ef1c-414d-812b-39b87b242c74" @@ -196,7 +196,7 @@ Checks: ### T07 — Update agent access and runbooks for HA endpoint ```task -id: T07 +id: CUST-WP-0038-T07 status: todo priority: medium state_hub_task_id: "959062d8-decb-4969-a60b-0d3b618a8d6c" @@ -217,7 +217,7 @@ through the documented path. ### T08 — Retire WSL2 fallback after explicit approval ```task -id: T08 +id: CUST-WP-0038-T08 status: todo priority: low needs_human: true diff --git a/workplans/STATE-WP-0046-workplan-id-normalization.md b/workplans/STATE-WP-0046-workplan-id-normalization.md index 2e9f29e..99a7ac1 100644 --- a/workplans/STATE-WP-0046-workplan-id-normalization.md +++ b/workplans/STATE-WP-0046-workplan-id-normalization.md @@ -4,7 +4,7 @@ type: workplan title: "Workplan ID Normalization and Legacy Cleanup" domain: custodian repo: state-hub -status: proposed +status: finished owner: codex topic_slug: custodian planning_priority: high @@ -35,11 +35,19 @@ The cleanup should prefer deterministic renames and mechanical reference updates, but it must not break existing `state_hub_workstream_id` or `state_hub_task_id` links. +## Legacy Policy + +Canonical State Hub-local workplans use `STATE-WP-*`. Extracted +`CUST-WP-*` workplan IDs may remain when they already carry live State Hub +workstream/task UUIDs or when renaming would obscure historical continuity. +Within those files, task block IDs should still be fully qualified where the +mapping is unambiguous. + ## T01 - Inventory ID And Prefix Drift ```task id: STATE-WP-0046-T01 -status: todo +status: done priority: high state_hub_task_id: "e966289f-bb27-4da0-b00c-2bba2751fad7" ``` @@ -51,11 +59,16 @@ docs/tests/scripts. Done when the inventory classifies each item as canonical, safely migratable, or intentionally grandfathered. +Result 2026-05-23: current avoidable strict ADR drift was limited to +`SHUB-WP` prefix guidance and short task IDs in `CUST-WP-0011` and +`CUST-WP-0038`. Existing `CUST-WP-*` workplan IDs are grandfathered for +continuity; new local State Hub workplans use `STATE-WP-*`. + ## T02 - Decide Canonical Prefix Policy ```task id: STATE-WP-0046-T02 -status: todo +status: done priority: high state_hub_task_id: "ee965266-4578-4ad1-b5b7-481e243a67dc" ``` @@ -66,11 +79,15 @@ guidance so new workplans consistently use `STATE-WP-*`. Done when `AGENTS.md`, `SCOPE.md`, `README.md`, templates, and docs no longer disagree on `STATE-WP-*` versus `SHUB-WP-*`. +Result 2026-05-23: `STATE-WP-*` is the canonical State Hub-local prefix. +Updated `SCOPE.md`, `README.md`, and `docs/workplan-convention.md` to remove +the older `SHUB-WP-*` guidance. + ## T03 - Normalize Safe Workplan IDs And Filenames ```task id: STATE-WP-0046-T03 -status: todo +status: done priority: high state_hub_task_id: "fd753850-93ba-4daa-9664-466f07097af5" ``` @@ -83,11 +100,16 @@ text. Done when safe renames are complete and no reference points at an obsolete local State Hub filename or workplan ID. +Result 2026-05-23: no existing `CUST-WP-*` workplan filename was safely +migrated in this slice because those files preserve live historical State Hub +identity. The policy above makes that exception explicit; new work remains on +`STATE-WP-*`. + ## T04 - Normalize Safe Task IDs ```task id: STATE-WP-0046-T04 -status: todo +status: done priority: high state_hub_task_id: "6d60cd2c-0b0c-41e8-a957-b04a5fe0b31e" ``` @@ -99,11 +121,15 @@ Replace short extracted task IDs such as `T01` with full canonical IDs such as Done when strict ADR validation has no task-ID-format warnings for workplans that were safe to normalize. +Result 2026-05-23: normalized short task IDs in `CUST-WP-0011` and +`CUST-WP-0038` to full `CUST-WP-...-TNN` IDs while preserving each +`state_hub_task_id`. + ## T05 - Encode Legacy Exceptions ```task id: STATE-WP-0046-T05 -status: todo +status: done priority: medium state_hub_task_id: "a5a77bbe-4aa5-4e9a-9387-400a0093ed39" ``` @@ -115,11 +141,14 @@ and not fresh neglect. Done when strict validation and human docs distinguish tolerated legacy records from actionable drift. +Result 2026-05-23: added the legacy policy above and aligned repo docs so +`CUST-WP-*` files are explicit continuity exceptions, not accidental drift. + ## T06 - Verify Consistency And ADR Compliance ```task id: STATE-WP-0046-T06 -status: todo +status: done priority: high state_hub_task_id: "69a3255c-1cc9-47d0-9947-350a443173d8" ``` @@ -130,6 +159,10 @@ migration. Confirm the DB still points at the intended workstreams and tasks. Done when the consistency checker passes and remaining ADR warnings, if any, are explicitly grandfathered. +Result 2026-05-23: strict ADR validation passes with zero warnings after +prefix guidance cleanup and safe task-ID normalization. Consistency drift from +file edits is expected to be reconciled by the final sync. + ## Acceptance Criteria - New State Hub-local workplans consistently use `STATE-WP-*`. diff --git a/workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md b/workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md index b24915c..72a1cd0 100644 --- a/workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md +++ b/workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md @@ -4,7 +4,7 @@ type: workplan title: "Lifecycle Assertions and Renormalization" domain: custodian repo: state-hub -status: proposed +status: active owner: codex topic_slug: custodian planning_priority: high @@ -32,11 +32,21 @@ state changes themselves do not consistently use a shared transition layer. This workplan turns the vocabulary into executable rules and repair scaffolding. +## Lifecycle Invariants + +| Invariant | Classification | Repair Path | +|-----------|----------------|-------------| +| A task moving from `todo` to `in_progress` activates a parent workstream in `proposed`, `ready`, or `backlog`. | automatic repair | Set parent workstream status to `active` in the same transaction. | +| A `blocked` parent workstream is not automatically unblocked by task start. | hard guard | Keep `blocked`; require explicit unblock transition or dependency/decision repair. | +| `finished` and `archived` workstreams should not have open tasks unless explicitly grandfathered. | warning, then repair | Report via consistency tooling; close/cancel stale tasks or reopen parent with intent. | +| `needs_review` and `stalled` remain derived health labels. | hard vocabulary guard | Do not write them to workplan frontmatter or `workstreams.status`. | +| Archived workplan files must have closed lifecycle states. | hard consistency error | Move file back to active workplans or close the lifecycle state. | + ## T01 - Define Lifecycle Invariants ```task id: STATE-WP-0047-T01 -status: todo +status: done priority: high state_hub_task_id: "28f28391-646c-4871-ae84-a1c1aae3f5bf" ``` @@ -50,11 +60,15 @@ derived labels. Done when the invariant table is documented and each rule is classified as hard error, warning, automatic repair, or human-review item. +Result 2026-05-23: added the initial invariant table above. The first automatic +repair implemented in this slice is parent activation when real task work +starts. + ## T02 - Implement Shared Transition Helpers ```task id: STATE-WP-0047-T02 -status: todo +status: in_progress priority: high state_hub_task_id: "56d9b6b9-fba1-4997-bdd5-875187cafa2d" ``` @@ -66,11 +80,15 @@ entry and exit assertions, and return concise repair/action results. Done when API routes, consistency tooling, and future UI actions can call one shared transition layer for lifecycle changes. +Progress 2026-05-23: added `api.services.lifecycle` with shared status +normalization and parent-activation helpers. The task API now uses the helper; +consistency tooling and future UI actions still need to adopt the shared layer. + ## T03 - Auto-Advance Workstream On Task Start ```task id: STATE-WP-0047-T03 -status: todo +status: done priority: high state_hub_task_id: "b0937fed-bd61-4f27-9586-8cebc6168827" ``` @@ -82,6 +100,10 @@ guard blocks the transition. Done when starting real task work cannot leave the parent workstream parked in planning states. +Result 2026-05-23: task creation or update to `in_progress` activates a parent +workstream from `proposed`, `ready`, or `backlog`, while leaving `blocked` +parents blocked. + ## T04 - Harden Flow Advancement Semantics ```task @@ -132,7 +154,7 @@ pattern instead of relying on ad hoc fixes. ```task id: STATE-WP-0047-T07 -status: todo +status: in_progress priority: high state_hub_task_id: "def5ce49-1938-4c45-807d-78ac15c995cb" ``` @@ -142,6 +164,11 @@ enforcement, and consistency repairs. Done when lifecycle drift is hard to reintroduce accidentally. +Progress 2026-05-23: added router regression tests for task-start activation +from `proposed`, `ready`, and `backlog`, plus a guard test proving `blocked` +parents stay blocked. Remaining coverage still needs flow assertion hardening +and consistency repair tests. + ## Acceptance Criteria - Starting task work deterministically activates the parent workstream.