generated from coulomb/repo-seed
feat(tasks): adopt canonical task statuses
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
|
|||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"status": "in_progress"}'
|
-d '{"status": "progress"}'
|
||||||
# values: todo | in_progress | done | blocked
|
# values: wait | todo | progress | done | cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Flag a task for human review
|
### 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)
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
2. Check inbox: `GET /messages/?to_agent=state-hub&unread_only=true`; mark read
|
2. Check inbox: `GET /messages/?to_agent=state-hub&unread_only=true`; mark read
|
||||||
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
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:**
|
**During work:**
|
||||||
- Update task statuses in workplan files as tasks progress
|
- Update task statuses in workplan files as tasks progress
|
||||||
@@ -146,7 +146,7 @@ derived health labels, not frontmatter statuses.
|
|||||||
|
|
||||||
` ` `task
|
` ` `task
|
||||||
id: STATE-WP-NNNN-T01
|
id: STATE-WP-NNNN-T01
|
||||||
status: todo | in_progress | done | blocked
|
status: wait | todo | progress | done | cancel
|
||||||
priority: high | medium | low
|
priority: high | medium | low
|
||||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
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.
|
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:
|
To create a new workplan:
|
||||||
1. Write the file following the format above
|
1. Write the file following the format above
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -142,7 +142,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
|
|||||||
|------|--------|
|
|------|--------|
|
||||||
| `topic_status` | `active` · `paused` · `archived` |
|
| `topic_status` | `active` · `paused` · `archived` |
|
||||||
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||||
| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` |
|
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
|
||||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
||||||
| `decision_type` | `made` · `pending` |
|
| `decision_type` | `made` · `pending` |
|
||||||
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
|
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
|
||||||
@@ -150,7 +150,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
|
|||||||
|
|
||||||
### Governance constraints encoded in schema
|
### Governance constraints encoded in schema
|
||||||
|
|
||||||
- No hard DELETE endpoints — only soft: `archived`, `cancelled`, `superseded`
|
- No hard DELETE endpoints — only soft: `archived`, `cancel`, `superseded`
|
||||||
- `progress_events` has no `updated_at` and no DELETE endpoint (append-only per constitution §5)
|
- `progress_events` has no `updated_at` and no DELETE endpoint (append-only per constitution §5)
|
||||||
- `decisions` with financial/legal keywords + `pending` type → auto-set `escalation_note` (§4)
|
- `decisions` with financial/legal keywords + `pending` type → auto-set `escalation_note` (§4)
|
||||||
|
|
||||||
@@ -170,12 +170,12 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|
|||||||
"totals": {
|
"totals": {
|
||||||
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
|
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
|
||||||
"workstreams": { "ready": 1, "active": 1, "blocked": 0, "finished": 1, "total": 3 },
|
"workstreams": { "ready": 1, "active": 1, "blocked": 0, "finished": 1, "total": 3 },
|
||||||
"tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 },
|
"tasks": { "wait": 0, "todo": 9, "progress": 0, "done": 11, "cancel": 0, "total": 20 },
|
||||||
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
|
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
|
||||||
},
|
},
|
||||||
"topics": [...], // topics with nested workstream stubs
|
"topics": [...], // topics with nested workstream stubs
|
||||||
"blocking_decisions": [...], // pending decisions only
|
"blocking_decisions": [...], // pending decisions only
|
||||||
"blocked_tasks": [...],
|
"waiting_tasks": [...],
|
||||||
"recent_progress": [...], // last 20 events
|
"recent_progress": [...], // last 20 events
|
||||||
"open_workstreams": [...]
|
"open_workstreams": [...]
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|
|||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| `/topics` | CRUD (soft-delete: `archived`) |
|
| `/topics` | CRUD (soft-delete: `archived`) |
|
||||||
| `/workstreams` | CRUD (soft-delete: `archived`) |
|
| `/workstreams` | CRUD (soft-delete: `archived`) |
|
||||||
| `/tasks` | CRUD (soft-delete: `cancelled`); `PATCH` updates status |
|
| `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status |
|
||||||
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
|
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
|
||||||
| `/progress` | `GET` list + `POST` append — no DELETE |
|
| `/progress` | `GET` list + `POST` append — no DELETE |
|
||||||
| `/state/summary` | Full snapshot |
|
| `/state/summary` | Full snapshot |
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from api.models.base import Base, TimestampMixin, new_uuid
|
|||||||
|
|
||||||
|
|
||||||
class TaskStatus(str, enum.Enum):
|
class TaskStatus(str, enum.Enum):
|
||||||
|
wait = "wait"
|
||||||
todo = "todo"
|
todo = "todo"
|
||||||
in_progress = "in_progress"
|
progress = "progress"
|
||||||
blocked = "blocked"
|
|
||||||
done = "done"
|
done = "done"
|
||||||
cancelled = "cancelled"
|
cancel = "cancel"
|
||||||
|
|
||||||
|
|
||||||
class TaskPriority(str, enum.Enum):
|
class TaskPriority(str, enum.Enum):
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ async def patch_request_status(
|
|||||||
# Auto-unblock the blocking task
|
# Auto-unblock the blocking task
|
||||||
if req.blocking_task_id:
|
if req.blocking_task_id:
|
||||||
task = await session.get(Task, req.blocking_task_id)
|
task = await session.get(Task, req.blocking_task_id)
|
||||||
if task and task.status == "blocked":
|
if task and task.status == "wait":
|
||||||
task.status = "todo"
|
task.status = "todo"
|
||||||
task.blocking_reason = None
|
task.blocking_reason = None
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ async def workplan_stack(
|
|||||||
]
|
]
|
||||||
blocked_tasks = [
|
blocked_tasks = [
|
||||||
task_id for task_id in task_deps.get(ws.id, [])
|
task_id for task_id in task_deps.get(ws.id, [])
|
||||||
if task_status.get(task_id) not in {"done", "cancelled"}
|
if task_status.get(task_id) not in {"done", "cancel"}
|
||||||
]
|
]
|
||||||
eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks
|
eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks
|
||||||
if not include_blocked and not eligible:
|
if not include_blocked and not eligible:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from api.services.lifecycle import (
|
|||||||
transition_task_status,
|
transition_task_status,
|
||||||
transition_workstream_status,
|
transition_workstream_status,
|
||||||
)
|
)
|
||||||
|
from api.task_status import TERMINAL_TASK_STATUSES
|
||||||
from api.services.reconciliation import (
|
from api.services.reconciliation import (
|
||||||
ReconciliationClass,
|
ReconciliationClass,
|
||||||
StateChangeClassification,
|
StateChangeClassification,
|
||||||
@@ -52,7 +53,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
|
|||||||
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
||||||
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
|
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
|
||||||
statuses = [status_value(row[0]) for row in result.all()]
|
statuses = [status_value(row[0]) for row in result.all()]
|
||||||
return bool(statuses) and all(status in {"done", "cancelled"} for status in statuses)
|
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
||||||
|
|
||||||
|
|
||||||
def _deferred_message(
|
def _deferred_message(
|
||||||
|
|||||||
@@ -590,7 +590,7 @@ async def get_repo_dispatch(
|
|||||||
for ws in workstreams:
|
for ws in workstreams:
|
||||||
task_result = await session.execute(
|
task_result = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "in_progress"]))
|
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"]))
|
||||||
.order_by(Task.created_at)
|
.order_by(Task.created_at)
|
||||||
)
|
)
|
||||||
tasks = list(task_result.scalars().all())
|
tasks = list(task_result.scalars().all())
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from api.schemas.task import TaskRead
|
|||||||
from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
||||||
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
||||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||||
|
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||||
from api.workplan_status import (
|
from api.workplan_status import (
|
||||||
CLOSED_WORKSTREAM_STATUSES,
|
CLOSED_WORKSTREAM_STATUSES,
|
||||||
OPEN_WORKSTREAM_STATUSES,
|
OPEN_WORKSTREAM_STATUSES,
|
||||||
@@ -111,10 +112,10 @@ async def get_summary(
|
|||||||
)
|
)
|
||||||
blocking = list(blocking_rows.scalars().all())
|
blocking = list(blocking_rows.scalars().all())
|
||||||
|
|
||||||
blocked_rows = await session.execute(
|
waiting_rows = await session.execute(
|
||||||
select(Task).options(noload("*")).where(Task.status == TaskStatus.blocked).order_by(Task.created_at)
|
select(Task).options(noload("*")).where(Task.status == TaskStatus.wait).order_by(Task.created_at)
|
||||||
)
|
)
|
||||||
blocked = list(blocked_rows.scalars().all())
|
waiting = list(waiting_rows.scalars().all())
|
||||||
|
|
||||||
recent_rows = await session.execute(
|
recent_rows = await session.execute(
|
||||||
select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20)
|
select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20)
|
||||||
@@ -136,7 +137,7 @@ async def get_summary(
|
|||||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||||
):
|
):
|
||||||
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
||||||
task_statuses_per_ws.setdefault(ws_id, []).extend([_value(tstat)] * cnt)
|
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
||||||
|
|
||||||
# Dependency graph for open workstreams
|
# Dependency graph for open workstreams
|
||||||
open_ws_ids = [w.id for w in open_ws]
|
open_ws_ids = [w.id for w in open_ws]
|
||||||
@@ -263,11 +264,11 @@ async def get_summary(
|
|||||||
total=sum(ws_counts.values()),
|
total=sum(ws_counts.values()),
|
||||||
),
|
),
|
||||||
tasks=TaskTotals(
|
tasks=TaskTotals(
|
||||||
|
wait=task_counts.get(TaskStatus.wait, 0),
|
||||||
todo=task_counts.get(TaskStatus.todo, 0),
|
todo=task_counts.get(TaskStatus.todo, 0),
|
||||||
in_progress=task_counts.get(TaskStatus.in_progress, 0),
|
progress=task_counts.get(TaskStatus.progress, 0),
|
||||||
blocked=task_counts.get(TaskStatus.blocked, 0),
|
|
||||||
done=task_counts.get(TaskStatus.done, 0),
|
done=task_counts.get(TaskStatus.done, 0),
|
||||||
cancelled=task_counts.get(TaskStatus.cancelled, 0),
|
cancel=task_counts.get(TaskStatus.cancel, 0),
|
||||||
total=sum(task_counts.values()),
|
total=sum(task_counts.values()),
|
||||||
),
|
),
|
||||||
decisions=DecisionTotals(
|
decisions=DecisionTotals(
|
||||||
@@ -329,7 +330,8 @@ async def get_summary(
|
|||||||
for t in topics
|
for t in topics
|
||||||
],
|
],
|
||||||
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
|
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
|
||||||
blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
|
waiting_tasks=[TaskRead.model_validate(t) for t in waiting],
|
||||||
|
blocked_tasks=[TaskRead.model_validate(t) for t in waiting],
|
||||||
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
||||||
next_steps=next_steps,
|
next_steps=next_steps,
|
||||||
domains=domain_summaries,
|
domains=domain_summaries,
|
||||||
@@ -343,10 +345,11 @@ async def get_summary(
|
|||||||
"status": effective_status.get(w.id, w.status),
|
"status": effective_status.get(w.id, w.status),
|
||||||
},
|
},
|
||||||
tasks_total=sum(task_per_ws.get(w.id, {}).values()),
|
tasks_total=sum(task_per_ws.get(w.id, {}).values()),
|
||||||
|
tasks_wait=task_per_ws.get(w.id, {}).get(TaskStatus.wait, 0),
|
||||||
tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0),
|
tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0),
|
||||||
tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0),
|
tasks_progress=task_per_ws.get(w.id, {}).get(TaskStatus.progress, 0),
|
||||||
tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0),
|
|
||||||
tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0),
|
tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0),
|
||||||
|
tasks_cancel=task_per_ws.get(w.id, {}).get(TaskStatus.cancel, 0),
|
||||||
depends_on=dep_index.get(w.id, {}).get("depends_on", []),
|
depends_on=dep_index.get(w.id, {}).get("depends_on", []),
|
||||||
blocks=dep_index.get(w.id, {}).get("blocks", []),
|
blocks=dep_index.get(w.id, {}).get("blocks", []),
|
||||||
blocked_reasons=blocked_reasons.get(w.id, []),
|
blocked_reasons=blocked_reasons.get(w.id, []),
|
||||||
@@ -521,7 +524,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
select(Task)
|
select(Task)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Task.workstream_id == decision.workstream_id)
|
.where(Task.workstream_id == decision.workstream_id)
|
||||||
.where(Task.status.in_([TaskStatus.todo, TaskStatus.in_progress]))
|
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
|
||||||
)
|
)
|
||||||
open_tasks = list(open_tasks_rows.scalars().all())
|
open_tasks = list(open_tasks_rows.scalars().all())
|
||||||
if not open_tasks:
|
if not open_tasks:
|
||||||
@@ -655,10 +658,6 @@ async def _get_domain_slug_for_topic(topic_id, session: AsyncSession) -> str | N
|
|||||||
return domain.slug if domain else None
|
return domain.slug if domain else None
|
||||||
|
|
||||||
|
|
||||||
def _value(item):
|
|
||||||
return item.value if hasattr(item, "value") else item
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/next_steps", response_model=list[NextStep])
|
@router.get("/next_steps", response_model=list[NextStep])
|
||||||
async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]:
|
async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]:
|
||||||
"""Derive contextual next-action suggestions from current hub state.
|
"""Derive contextual next-action suggestions from current hub state.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from api.models.token_event import TokenEvent
|
|||||||
from api.models.workstream import Workstream
|
from api.models.workstream import Workstream
|
||||||
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
||||||
from api.services.lifecycle import status_value, transition_task_status
|
from api.services.lifecycle import status_value, transition_task_status
|
||||||
|
from api.task_status import normalize_task_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
|
|||||||
@router.get("/", response_model=list[TaskRead])
|
@router.get("/", response_model=list[TaskRead])
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
workstream_id: uuid.UUID | None = None,
|
workstream_id: uuid.UUID | None = None,
|
||||||
status: TaskStatus | None = None,
|
status: str | None = None,
|
||||||
assignee: str | None = None,
|
assignee: str | None = None,
|
||||||
needs_human: bool | None = Query(None),
|
needs_human: bool | None = Query(None),
|
||||||
priority: str | None = None,
|
priority: str | None = None,
|
||||||
@@ -29,7 +30,7 @@ async def list_tasks(
|
|||||||
if workstream_id:
|
if workstream_id:
|
||||||
q = q.where(Task.workstream_id == workstream_id)
|
q = q.where(Task.workstream_id == workstream_id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Task.status == status)
|
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||||
if assignee:
|
if assignee:
|
||||||
q = q.where(Task.assignee == assignee)
|
q = q.where(Task.assignee == assignee)
|
||||||
if needs_human is not None:
|
if needs_human is not None:
|
||||||
@@ -50,7 +51,7 @@ async def create_task(
|
|||||||
) -> Task:
|
) -> Task:
|
||||||
task = Task(**body.model_dump())
|
task = Task(**body.model_dump())
|
||||||
session.add(task)
|
session.add(task)
|
||||||
if status_value(task.status) == "in_progress":
|
if status_value(task.status) == "progress":
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workstream, task.workstream_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
task,
|
task,
|
||||||
@@ -198,7 +199,7 @@ async def cancel_task(
|
|||||||
task = await session.get(Task, task_id)
|
task = await session.get(Task, task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
transition_task_status(task, TaskStatus.cancelled)
|
transition_task_status(task, TaskStatus.cancel)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(task)
|
await session.refresh(task)
|
||||||
return task
|
return task
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ class WorkstreamTotals(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TaskTotals(BaseModel):
|
class TaskTotals(BaseModel):
|
||||||
|
wait: int = 0
|
||||||
todo: int = 0
|
todo: int = 0
|
||||||
in_progress: int = 0
|
progress: int = 0
|
||||||
blocked: int = 0
|
|
||||||
done: int = 0
|
done: int = 0
|
||||||
cancelled: int = 0
|
cancel: int = 0
|
||||||
total: int = 0
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -75,7 +75,8 @@ class StateSummary(BaseModel):
|
|||||||
totals: Totals
|
totals: Totals
|
||||||
topics: list[TopicWithWorkstreams]
|
topics: list[TopicWithWorkstreams]
|
||||||
blocking_decisions: list[DecisionRead]
|
blocking_decisions: list[DecisionRead]
|
||||||
blocked_tasks: list[TaskRead]
|
waiting_tasks: list[TaskRead]
|
||||||
|
blocked_tasks: list[TaskRead] = []
|
||||||
recent_progress: list[ProgressEventRead]
|
recent_progress: list[ProgressEventRead]
|
||||||
open_workstreams: list[WorkstreamWithDeps]
|
open_workstreams: list[WorkstreamWithDeps]
|
||||||
next_steps: list[NextStep] = []
|
next_steps: list[NextStep] = []
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ import uuid
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, model_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
from api.models.task import TaskPriority, TaskStatus
|
from api.models.task import TaskPriority, TaskStatus
|
||||||
|
from api.task_status import normalize_task_status
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskStatusMixin(BaseModel):
|
||||||
|
@field_validator("status", mode="before", check_fields=False)
|
||||||
|
@classmethod
|
||||||
|
def _normalize_status(cls, value):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return normalize_task_status(value)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreate(TaskStatusMixin):
|
||||||
workstream_id: uuid.UUID
|
workstream_id: uuid.UUID
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -27,7 +37,7 @@ class TaskCreate(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(TaskStatusMixin):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus | None = None
|
status: TaskStatus | None = None
|
||||||
@@ -55,9 +65,9 @@ class TaskUpdate(BaseModel):
|
|||||||
suppress_token_event: bool | None = None
|
suppress_token_event: bool | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def blocking_reason_required_when_blocked(self) -> Self:
|
def blocking_reason_required_when_human_waiting(self) -> Self:
|
||||||
if self.status == TaskStatus.blocked and not self.blocking_reason:
|
if self.status == TaskStatus.wait and self.needs_human and not self.blocking_reason:
|
||||||
raise ValueError("blocking_reason is required when status is blocked")
|
raise ValueError("blocking_reason is required when a human-blocked task is waiting")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -67,7 +77,7 @@ class TaskUpdate(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TaskRead(BaseModel):
|
class TaskRead(TaskStatusMixin):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
workstream_id: uuid.UUID
|
workstream_id: uuid.UUID
|
||||||
|
|||||||
@@ -92,10 +92,11 @@ class WorkstreamRead(WorkstreamStatusMixin):
|
|||||||
|
|
||||||
class WorkstreamWithTaskCounts(WorkstreamRead):
|
class WorkstreamWithTaskCounts(WorkstreamRead):
|
||||||
tasks_total: int = 0
|
tasks_total: int = 0
|
||||||
|
tasks_wait: int = 0
|
||||||
tasks_todo: int = 0
|
tasks_todo: int = 0
|
||||||
tasks_in_progress: int = 0
|
tasks_progress: int = 0
|
||||||
tasks_blocked: int = 0
|
|
||||||
tasks_done: int = 0
|
tasks_done: int = 0
|
||||||
|
tasks_cancel: int = 0
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
|
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workstream_status
|
||||||
|
|
||||||
|
|
||||||
TASK_STARTED_STATUS = "in_progress"
|
TASK_STARTED_STATUS = "progress"
|
||||||
TASK_NOT_STARTED_STATUS = "todo"
|
TASK_NOT_STARTED_STATUS = "todo"
|
||||||
TASK_ACTIVE_STATUSES = {"in_progress", "blocked"}
|
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
|
||||||
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
||||||
|
|
||||||
|
|
||||||
@@ -21,12 +22,6 @@ class LifecycleTransitionResult:
|
|||||||
parent_activated: bool = False
|
parent_activated: bool = False
|
||||||
|
|
||||||
|
|
||||||
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(
|
def should_activate_parent_for_task_start(
|
||||||
*,
|
*,
|
||||||
previous_task_status: Any,
|
previous_task_status: Any,
|
||||||
@@ -109,8 +104,8 @@ def transition_task_status(
|
|||||||
if previous_task_status is None
|
if previous_task_status is None
|
||||||
else previous_task_status
|
else previous_task_status
|
||||||
)
|
)
|
||||||
normalised_target = status_value(target_status)
|
normalised_target = normalize_task_status(target_status)
|
||||||
task.status = status_coercer(normalised_target) if status_coercer else target_status
|
task.status = status_coercer(normalised_target) if status_coercer else normalised_target
|
||||||
parent_activated = activate_parent_for_task_start(
|
parent_activated = activate_parent_for_task_start(
|
||||||
previous_task_status=previous_status,
|
previous_task_status=previous_status,
|
||||||
new_task_status=normalised_target,
|
new_task_status=normalised_target,
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ async def collect_domain_activity(
|
|||||||
]
|
]
|
||||||
attention_tasks = [
|
attention_tasks = [
|
||||||
task for task in tasks
|
task for task in tasks
|
||||||
if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancelled.value}
|
if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancel.value}
|
||||||
]
|
]
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from enum import Enum
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api.services.lifecycle import status_value
|
from api.services.lifecycle import status_value
|
||||||
|
from api.task_status import CANONICAL_TASK_STATUSES
|
||||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ class StateChangeClassification:
|
|||||||
|
|
||||||
|
|
||||||
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
|
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
|
||||||
TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
def classify_workstream_status_change(
|
def classify_workstream_status_change(
|
||||||
@@ -129,11 +130,11 @@ def classify_task_status_change(
|
|||||||
"status is unchanged",
|
"status is unchanged",
|
||||||
"no file update required",
|
"no file update required",
|
||||||
)
|
)
|
||||||
if target == "blocked" and not (blocking_reason or "").strip():
|
if target == "wait" and not (blocking_reason or "").strip():
|
||||||
return StateChangeClassification(
|
return StateChangeClassification(
|
||||||
ReconciliationClass.HUMAN_CONFIRMATION,
|
ReconciliationClass.HUMAN_CONFIRMATION,
|
||||||
"blocked tasks require a blocking reason",
|
"waiting tasks should explain the wait condition",
|
||||||
"capture the blocker before writing status",
|
"capture the wait reason before writing status",
|
||||||
)
|
)
|
||||||
if target in TASK_STATUSES:
|
if target in TASK_STATUSES:
|
||||||
return StateChangeClassification(
|
return StateChangeClassification(
|
||||||
|
|||||||
84
api/task_status.py
Normal file
84
api/task_status.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CANONICAL_TASK_STATUSES: tuple[str, ...] = (
|
||||||
|
"wait",
|
||||||
|
"todo",
|
||||||
|
"progress",
|
||||||
|
"done",
|
||||||
|
"cancel",
|
||||||
|
)
|
||||||
|
|
||||||
|
TASK_STATUS_CODES: dict[str, str] = {
|
||||||
|
"WAIT": "wait",
|
||||||
|
"TODO": "todo",
|
||||||
|
"PROG": "progress",
|
||||||
|
"DONE": "done",
|
||||||
|
"CNCL": "cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
LEGACY_TASK_STATUS_ALIASES: dict[str, str] = {
|
||||||
|
"blocked": "wait",
|
||||||
|
"waiting": "wait",
|
||||||
|
"in_progress": "progress",
|
||||||
|
"in-progress": "progress",
|
||||||
|
"prog": "progress",
|
||||||
|
"cancelled": "cancel",
|
||||||
|
"canceled": "cancel",
|
||||||
|
"cncl": "cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
OPEN_TASK_STATUSES: frozenset[str] = frozenset({"wait", "todo", "progress"})
|
||||||
|
ACTIVE_TASK_STATUSES: frozenset[str] = frozenset({"wait", "progress"})
|
||||||
|
TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({"done", "cancel"})
|
||||||
|
|
||||||
|
TASK_STATUS_ORDER: dict[str, int] = {
|
||||||
|
"todo": 0,
|
||||||
|
"wait": 1,
|
||||||
|
"progress": 1,
|
||||||
|
"done": 2,
|
||||||
|
"cancel": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
TASK_STATUS_LABELS: dict[str, str] = {
|
||||||
|
"wait": "wait",
|
||||||
|
"todo": "todo",
|
||||||
|
"progress": "progress",
|
||||||
|
"done": "done",
|
||||||
|
"cancel": "cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def raw_status_value(status: Any) -> str:
|
||||||
|
if hasattr(status, "value"):
|
||||||
|
status = status.value
|
||||||
|
return str(status or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_task_status(status: Any, *, default: str | None = None) -> str:
|
||||||
|
"""Normalize canon task statuses plus legacy aliases to stored values."""
|
||||||
|
value = raw_status_value(status).lower()
|
||||||
|
if not value:
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
raise ValueError("task status is required")
|
||||||
|
value = LEGACY_TASK_STATUS_ALIASES.get(value, value)
|
||||||
|
if value not in CANONICAL_TASK_STATUSES:
|
||||||
|
allowed = ", ".join(CANONICAL_TASK_STATUSES)
|
||||||
|
aliases = ", ".join(sorted(LEGACY_TASK_STATUS_ALIASES))
|
||||||
|
raise ValueError(f"task status must be one of {allowed}; legacy aliases: {aliases}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def status_value(status: Any, *, default: str | None = None) -> str:
|
||||||
|
"""Compatibility wrapper used by lifecycle and reconciliation code."""
|
||||||
|
return normalize_task_status(status, default=default)
|
||||||
|
|
||||||
|
|
||||||
|
def is_open_task_status(status: Any) -> bool:
|
||||||
|
return normalize_task_status(status, default="todo") in OPEN_TASK_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def is_terminal_task_status(status: Any) -> bool:
|
||||||
|
return normalize_task_status(status, default="todo") in TERMINAL_TASK_STATUSES
|
||||||
@@ -141,11 +141,14 @@ const _STATUS_STYLE = {
|
|||||||
archived: "background:#e2e3e5;color:#383d41",
|
archived: "background:#e2e3e5;color:#383d41",
|
||||||
open: "background:#dbeafe;color:#1e40af",
|
open: "background:#dbeafe;color:#1e40af",
|
||||||
in_progress: "background:#fef3c7;color:#92400e",
|
in_progress: "background:#fef3c7;color:#92400e",
|
||||||
|
wait: "background:#fef3c7;color:#92400e",
|
||||||
|
progress: "background:#ede9fe;color:#5b21b6",
|
||||||
addressed: "background:#dcfce7;color:#166534",
|
addressed: "background:#dcfce7;color:#166534",
|
||||||
deferred: "background:#f1f5f9;color:#64748b",
|
deferred: "background:#f1f5f9;color:#64748b",
|
||||||
wont_fix: "background:#f3f4f6;color:#9ca3af",
|
wont_fix: "background:#f3f4f6;color:#9ca3af",
|
||||||
todo: "background:#f1f5f9;color:#475569",
|
todo: "background:#f1f5f9;color:#475569",
|
||||||
done: "background:#dcfce7;color:#166534",
|
done: "background:#dcfce7;color:#166534",
|
||||||
|
cancel: "background:#f3f4f6;color:#9ca3af",
|
||||||
cancelled: "background:#f3f4f6;color:#9ca3af",
|
cancelled: "background:#f3f4f6;color:#9ca3af",
|
||||||
resolved: "background:#dcfce7;color:#166534",
|
resolved: "background:#dcfce7;color:#166534",
|
||||||
superseded: "background:#e2e3e5;color:#383d41",
|
superseded: "background:#e2e3e5;color:#383d41",
|
||||||
@@ -226,8 +229,8 @@ function _buildBody(entity, type) {
|
|||||||
if (entity.tasks_total !== undefined) {
|
if (entity.tasks_total !== undefined) {
|
||||||
els.push(_divider(),
|
els.push(_divider(),
|
||||||
tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` +
|
tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` +
|
||||||
(entity.tasks_in_progress > 0 ? ` · ${entity.tasks_in_progress} in progress` : "") +
|
(entity.tasks_progress > 0 ? ` · ${entity.tasks_progress} progress` : "") +
|
||||||
(entity.tasks_blocked > 0 ? ` · ${entity.tasks_blocked} blocked` : ""))
|
(entity.tasks_wait > 0 ? ` · ${entity.tasks_wait} wait` : ""))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (entity.depends_on?.length) {
|
if (entity.depends_on?.length) {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export const FIELD_HELP = {
|
|||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
label: "Status",
|
label: "Status",
|
||||||
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
|
description: "Current lifecycle state. Tasks use wait, todo, progress, done, or cancel.",
|
||||||
doc: "/docs/workstream-lifecycle",
|
doc: "/docs/workstream-lifecycle",
|
||||||
},
|
},
|
||||||
topic_id: {
|
topic_id: {
|
||||||
@@ -182,7 +182,7 @@ export const FIELD_HELP = {
|
|||||||
},
|
},
|
||||||
needs_human: {
|
needs_human: {
|
||||||
label: "Needs Human",
|
label: "Needs Human",
|
||||||
description: "True if the task is blocked waiting for human input or approval.",
|
description: "True if the task is waiting for human input or approval.",
|
||||||
doc: "/interventions",
|
doc: "/interventions",
|
||||||
},
|
},
|
||||||
intervention_note: {
|
intervention_note: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {WORKSTREAM_STATUSES} from "./workplan-status.js";
|
|||||||
|
|
||||||
const STYLE_ID = "status-control-styles";
|
const STYLE_ID = "status-control-styles";
|
||||||
|
|
||||||
export const TASK_STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"];
|
export const TASK_STATUSES = ["wait", "todo", "progress", "done", "cancel"];
|
||||||
export {WORKSTREAM_STATUSES};
|
export {WORKSTREAM_STATUSES};
|
||||||
|
|
||||||
function ensureStyles() {
|
function ensureStyles() {
|
||||||
@@ -138,9 +138,9 @@ export function statusControl({
|
|||||||
if (nextStatus === currentStatus) return;
|
if (nextStatus === currentStatus) return;
|
||||||
|
|
||||||
let blockingReason = null;
|
let blockingReason = null;
|
||||||
if (type === "task" && nextStatus === "blocked") {
|
if (type === "task" && nextStatus === "wait") {
|
||||||
const existingReason = entity?.blocking_reason ?? "";
|
const existingReason = entity?.blocking_reason ?? "";
|
||||||
const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:");
|
const reason = existingReason || window.prompt("Reason this task is waiting:");
|
||||||
if (!reason) {
|
if (!reason) {
|
||||||
select.value = currentStatus;
|
select.value = currentStatus;
|
||||||
setMessage("unchanged");
|
setMessage("unchanged");
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function isOpenWorkstream(status) {
|
|||||||
|
|
||||||
export function isStalledWorkstream(w, staleDays = 7) {
|
export function isStalledWorkstream(w, staleDays = 7) {
|
||||||
const staleAt = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000);
|
const staleAt = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000);
|
||||||
const openTasks = (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0);
|
const openTasks = (w.todo ?? 0) + (w.progress ?? 0) + (w.wait ?? 0);
|
||||||
return ["active", "blocked"].includes(normalizeWorkstreamStatus(w.status))
|
return ["active", "blocked"].includes(normalizeWorkstreamStatus(w.status))
|
||||||
&& new Date(w.updated_at) < staleAt
|
&& new Date(w.updated_at) < staleAt
|
||||||
&& (w.done ?? 0) > 0
|
&& (w.done ?? 0) > 0
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ except urllib.error.URLError as e:
|
|||||||
"archived": 0,
|
"archived": 0,
|
||||||
"total": 0,
|
"total": 0,
|
||||||
},
|
},
|
||||||
"tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0},
|
"tasks": {"wait": 0, "todo": 0, "progress": 0, "done": 0, "cancel": 0, "total": 0},
|
||||||
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
|
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
|
||||||
},
|
},
|
||||||
"topics": [],
|
"topics": [],
|
||||||
"blocking_decisions": [],
|
"blocking_decisions": [],
|
||||||
|
"waiting_tasks": [],
|
||||||
"blocked_tasks": [],
|
"blocked_tasks": [],
|
||||||
"recent_progress": [],
|
"recent_progress": [],
|
||||||
"open_workstreams": [],
|
"open_workstreams": [],
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ Notifications appear in the [Inbox](/inbox) page and are queryable via
|
|||||||
|
|
||||||
A request can optionally link to a **blocking task** via `blocking_task_id`.
|
A request can optionally link to a **blocking task** via `blocking_task_id`.
|
||||||
When the request reaches `completed`, the system automatically patches that
|
When the request reaches `completed`, the system automatically patches that
|
||||||
task from `blocked` → `todo` and clears its `blocking_reason`. This means
|
task from `wait` → `todo` and clears its `blocking_reason`. This means
|
||||||
blocked work resumes without manual intervention.
|
waiting work resumes without manual intervention.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ TOC sidebar as a persistent KPI card.
|
|||||||
### Multi-mode workstream chart
|
### Multi-mode workstream chart
|
||||||
|
|
||||||
The Overview page renders a horizontal stacked bar chart using `@observablehq/plot`
|
The Overview page renders a horizontal stacked bar chart using `@observablehq/plot`
|
||||||
showing task counts (done / in progress / blocked / todo) per workstream.
|
showing task counts (done / progress / wait / todo) per workstream.
|
||||||
A `<select>` dropdown switches between:
|
A `<select>` dropdown switches between:
|
||||||
|
|
||||||
- **Lifecycle modes**: proposed, ready, active, blocked, backlog, finished, archived
|
- **Lifecycle modes**: proposed, ready, active, blocked, backlog, finished, archived
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ The session orientation protocol (every repo's CLAUDE.md) surfaces todos from
|
|||||||
two sources:
|
two sources:
|
||||||
|
|
||||||
**Internal todos** (Step 2 of orientation) — workplan files in `workplans/`
|
**Internal todos** (Step 2 of orientation) — workplan files in `workplans/`
|
||||||
whose stored workstation/status label is `active`, with tasks in `todo` or
|
whose stored workstation/status label is `active`, with tasks in `wait`,
|
||||||
`in_progress`.
|
`todo`, or `progress`.
|
||||||
|
|
||||||
**Ecosystem todos targeting this repo** (Step 1 of orientation) —
|
**Ecosystem todos targeting this repo** (Step 1 of orientation) —
|
||||||
`get_state_summary()` returns all open tasks across all workstreams. The session
|
`get_state_summary()` returns all open tasks across all workstreams. The session
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ The Interventions page lists every task that has been flagged `needs_human=true`
|
|||||||
|
|
||||||
## What is a human intervention?
|
## What is a human intervention?
|
||||||
|
|
||||||
A human intervention is a task that an agent has determined cannot proceed without direct human action — for example, approving a financial decision, confirming a legal commitment, or resolving a sensitive ambiguity. Flagging a task does not change its work status; the task continues to be `todo`, `in_progress`, or `blocked` while awaiting attention.
|
A human intervention is a task that an agent has determined cannot proceed without direct human action — for example, approving a financial decision, confirming a legal commitment, or resolving a sensitive ambiguity. Flagging a task does not change its work status; the task continues to be `wait`, `todo`, or `progress` while awaiting attention.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ A human intervention is a task that an agent has determined cannot proceed witho
|
|||||||
|
|
||||||
## Open section
|
## Open section
|
||||||
|
|
||||||
Tasks are sorted by priority (critical → high → medium → low), then by status (blocked → in_progress → todo). Each card shows:
|
Tasks are sorted by priority (critical → high → medium → low), then by status (wait → progress → todo). Each card shows:
|
||||||
|
|
||||||
| Element | Meaning |
|
| Element | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -43,7 +43,7 @@ Tasks are sorted by priority (critical → high → medium → low), then by sta
|
|||||||
|
|
||||||
Click **Mark done** on any open card. An inline form appears requiring a **resolution comment** — a short note describing what was done. The comment is mandatory; clicking **Confirm** without entering text highlights the field in red and does nothing.
|
Click **Mark done** on any open card. An inline form appears requiring a **resolution comment** — a short note describing what was done. The comment is mandatory; clicking **Confirm** without entering text highlights the field in red and does nothing.
|
||||||
|
|
||||||
Once confirmed, the API call sets `status = done`, `needs_human = false`, and replaces the action note with your resolution comment. The card moves to the **Completed / Cancelled** section on the next poll.
|
Once confirmed, the API call sets `status = done`, `needs_human = false`, and replaces the action note with your resolution comment. The card moves to the **Completed / Canceled** section on the next poll.
|
||||||
|
|
||||||
Click **Cancel** to dismiss the form without making changes.
|
Click **Cancel** to dismiss the form without making changes.
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ by repository. Each bar is broken into four task-status segments:
|
|||||||
| Colour | Segment |
|
| Colour | Segment |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| green | done |
|
| green | done |
|
||||||
| blue | in progress |
|
| purple | progress |
|
||||||
| orange-red | blocked |
|
| orange | wait |
|
||||||
| light grey | todo |
|
| light grey | todo |
|
||||||
|
|
||||||
The left axis shows the `domain / repository` label once per repository group.
|
The left axis shows the `domain / repository` label once per repository group.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ These types are used by the State Hub's built-in write operations:
|
|||||||
| `workstream_status_changed` | Workstream moved between canonical lifecycle states |
|
| `workstream_status_changed` | Workstream moved between canonical lifecycle states |
|
||||||
| `workstation_advanced` | Flow-aware movement via `advance_workstation()` succeeded |
|
| `workstation_advanced` | Flow-aware movement via `advance_workstation()` succeeded |
|
||||||
| `task_created` | A new task was added to a workstream |
|
| `task_created` | A new task was added to a workstream |
|
||||||
| `task_status_changed` | Task moved to todo / in_progress / blocked / done / cancelled |
|
| `task_status_changed` | Task moved to wait / todo / progress / done / cancel |
|
||||||
| `decision_recorded` | A decision (pending or made) was recorded |
|
| `decision_recorded` | A decision (pending or made) was recorded |
|
||||||
| `decision_resolved` | A pending decision was resolved |
|
| `decision_resolved` | A pending decision was resolved |
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ priority: medium
|
|||||||
| Field | Values | Description |
|
| Field | Values | Description |
|
||||||
|-------|--------|-------------|
|
|-------|--------|-------------|
|
||||||
| `id` | string | Unique task identifier |
|
| `id` | string | Unique task identifier |
|
||||||
| `status` | `todo` \| `in_progress` \| `done` | Task state |
|
| `status` | `wait` \| `todo` \| `progress` \| `done` \| `cancel` | Task state |
|
||||||
| `priority` | `high` \| `medium` \| `low` | Execution order hint |
|
| `priority` | `high` \| `medium` \| `low` | Execution order hint |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -137,8 +137,8 @@ priority: medium
|
|||||||
As Claude completes tasks it edits the workplan file directly:
|
As Claude completes tasks it edits the workplan file directly:
|
||||||
|
|
||||||
```
|
```
|
||||||
status: todo → status: in_progress (when starting)
|
status: todo → status: progress (when starting)
|
||||||
status: in_progress → status: done (when verified complete)
|
status: progress → status: done (when verified complete)
|
||||||
```
|
```
|
||||||
|
|
||||||
When every task is `done`, Claude also updates the frontmatter:
|
When every task is `done`, Claude also updates the frontmatter:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ title: Tasks — Reference
|
|||||||
# Tasks — Reference
|
# Tasks — Reference
|
||||||
|
|
||||||
The Tasks page shows all tasks across every workstream and domain, with live
|
The Tasks page shows all tasks across every workstream and domain, with live
|
||||||
filtering, a workstation distribution chart, and a blocked-tasks highlight
|
filtering, a workstation distribution chart, and a waiting-tasks highlight
|
||||||
section.
|
section.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -17,11 +17,11 @@ compatibility.
|
|||||||
|
|
||||||
| Workstation | Meaning |
|
| Workstation | Meaning |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
|
| **wait** | Waiting on another actor, event, decision, input, or condition |
|
||||||
| **todo** | Not yet started |
|
| **todo** | Not yet started |
|
||||||
| **in_progress** | Actively being worked on |
|
| **progress** | Actively being worked on |
|
||||||
| **blocked** | Cannot proceed — has a blocking reason |
|
|
||||||
| **done** | Completed |
|
| **done** | Completed |
|
||||||
| **cancelled** | Dropped; not counted toward totals |
|
| **cancel** | Stopped; not counted toward totals |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -56,37 +56,37 @@ per stored workstation/status label, colour-coded:
|
|||||||
|
|
||||||
| Colour | Status |
|
| Colour | Status |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
|
| orange | wait |
|
||||||
| grey-blue | todo |
|
| grey-blue | todo |
|
||||||
| blue | in_progress |
|
| purple | progress |
|
||||||
| red | blocked |
|
|
||||||
| green | done |
|
| green | done |
|
||||||
| light grey | cancelled |
|
| light grey | cancel |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Blocked Tasks section
|
## Waiting Tasks section
|
||||||
|
|
||||||
Shows cards for every task currently in the `blocked` workstation within the
|
Shows cards for every task currently in the `wait` workstation within the
|
||||||
active filter. Each card displays:
|
active filter. Each card displays:
|
||||||
|
|
||||||
- Priority badge and status
|
- Priority badge and status
|
||||||
- Domain and workstream context
|
- Domain and workstream context
|
||||||
- Task title
|
- Task title
|
||||||
- Blocking reason (amber background)
|
- Wait reason (amber background)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## KPI sidebar card
|
## KPI sidebar card
|
||||||
|
|
||||||
Shows four counts for the unfiltered dataset: open (todo + in_progress +
|
Shows counts for the unfiltered dataset: open (`wait` + `todo` + `progress`),
|
||||||
blocked), blocked, in progress, done, and a done-% of total.
|
waiting, progress, done, and a done-% of total.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sorting
|
## Sorting
|
||||||
|
|
||||||
Tasks are sorted by status (blocked first, then in_progress, todo, done,
|
Tasks are sorted by status (wait first, then progress, todo, done, cancel) then
|
||||||
cancelled) then by priority (critical → high → medium → low) within each
|
by priority (critical → high → medium → low) within each
|
||||||
status group.
|
status group.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ boundary rule and routing workflows.
|
|||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
|
|
||||||
Open tasks (`todo`, `in_progress`, `blocked`) in **custodian domain workstreams**
|
Open tasks (`wait`, `todo`, `progress`) in **custodian domain workstreams**
|
||||||
whose title does not contain a `[repo:]` routing prefix.
|
whose title does not contain a `[repo:]` routing prefix.
|
||||||
|
|
||||||
These are tasks this agent is directly responsible for and can address within
|
These are tasks this agent is directly responsible for and can address within
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ const pageState = (async function*() {
|
|||||||
const counts = {};
|
const counts = {};
|
||||||
for (const t of taskList) {
|
for (const t of taskList) {
|
||||||
const wid = t.workstream_id;
|
const wid = t.workstream_id;
|
||||||
if (!counts[wid]) counts[wid] = {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0};
|
if (!counts[wid]) counts[wid] = {done: 0, progress: 0, wait: 0, todo: 0, total: 0};
|
||||||
counts[wid].total++;
|
counts[wid].total++;
|
||||||
if (t.status === "done") counts[wid].done++;
|
if (t.status === "done") counts[wid].done++;
|
||||||
else if (t.status === "in_progress") counts[wid].in_progress++;
|
else if (t.status === "progress") counts[wid].progress++;
|
||||||
else if (t.status === "blocked") counts[wid].blocked++;
|
else if (t.status === "wait") counts[wid].wait++;
|
||||||
else if (t.status === "todo") counts[wid].todo++;
|
else if (t.status === "todo") counts[wid].todo++;
|
||||||
}
|
}
|
||||||
wsAll = wsList.map(w => {
|
wsAll = wsList.map(w => {
|
||||||
const repo = repoMap[w.repo_id];
|
const repo = repoMap[w.repo_id];
|
||||||
@@ -78,7 +78,7 @@ const pageState = (async function*() {
|
|||||||
workplan_archived: workplan.archived ?? false,
|
workplan_archived: workplan.archived ?? false,
|
||||||
health_labels: workplan.health_labels ?? [],
|
health_labels: workplan.health_labels ?? [],
|
||||||
href: `./workstreams/${w.id}`,
|
href: `./workstreams/${w.id}`,
|
||||||
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
|
...(counts[w.id] ?? {done: 0, progress: 0, wait: 0, todo: 0, total: 0}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -233,13 +233,13 @@ for (const w of chartWs) {
|
|||||||
_seen.add(group);
|
_seen.add(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOrder = ["done", "in progress", "blocked", "todo"];
|
const statusOrder = ["done", "progress", "wait", "todo"];
|
||||||
const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"];
|
const statusColors = ["#4caf50", "#8b5cf6", "#f59e0b", "#e0e0e0"];
|
||||||
|
|
||||||
const _taskRows = chartWs.flatMap(w => [
|
const _taskRows = chartWs.flatMap(w => [
|
||||||
{id: w.id, title: w.title, status: "done", count: w.done ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
{id: w.id, title: w.title, status: "done", count: w.done ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
||||||
{id: w.id, title: w.title, status: "in progress", count: w.in_progress ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
{id: w.id, title: w.title, status: "progress", count: w.progress ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
||||||
{id: w.id, title: w.title, status: "blocked", count: w.blocked ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
{id: w.id, title: w.title, status: "wait", count: w.wait ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
||||||
{id: w.id, title: w.title, status: "todo", count: w.todo ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
{id: w.id, title: w.title, status: "todo", count: w.todo ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
|
||||||
]).filter(d => d.count > 0);
|
]).filter(d => d.count > 0);
|
||||||
|
|
||||||
@@ -370,7 +370,7 @@ display(html`<div class="grid grid-cols-3" style="gap:1rem;margin-bottom:1.5rem"
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const blockedTasks = summary.blocked_tasks ?? [];
|
const waitingTasks = summary.waiting_tasks ?? summary.blocked_tasks ?? [];
|
||||||
const wsById = Object.fromEntries((summary.open_workstreams ?? []).map(w => [w.id, w]));
|
const wsById = Object.fromEntries((summary.open_workstreams ?? []).map(w => [w.id, w]));
|
||||||
const todayCount = (summary.recent_progress ?? []).filter(e =>
|
const todayCount = (summary.recent_progress ?? []).filter(e =>
|
||||||
e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length;
|
e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length;
|
||||||
@@ -388,9 +388,9 @@ const statusEl = html`<div>
|
|||||||
<p class="big-num">${decCount}</p>
|
<p class="big-num">${decCount}</p>
|
||||||
<small>${decisions.escalated ?? 0} escalated</small>
|
<small>${decisions.escalated ?? 0} escalated</small>
|
||||||
</a>
|
</a>
|
||||||
<div class="card card-link ${blockedTasks.length > 0 ? 'warn' : ''}" data-toggle="blocked-panel">
|
<div class="card card-link ${waitingTasks.length > 0 ? 'warn' : ''}" data-toggle="waiting-panel">
|
||||||
<h3>Blocked Tasks</h3>
|
<h3>Waiting Tasks</h3>
|
||||||
<p class="big-num">${blockedTasks.length}</p>
|
<p class="big-num">${waitingTasks.length}</p>
|
||||||
<small>of ${tasks.total ?? 0} total · click to expand</small>
|
<small>of ${tasks.total ?? 0} total · click to expand</small>
|
||||||
</div>
|
</div>
|
||||||
<a class="card card-link" href="#recent-activity">
|
<a class="card card-link" href="#recent-activity">
|
||||||
@@ -400,10 +400,10 @@ const statusEl = html`<div>
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="blocked-panel" style="display:none;margin-bottom:1rem">
|
<div id="waiting-panel" style="display:none;margin-bottom:1rem">
|
||||||
${blockedTasks.length === 0
|
${waitingTasks.length === 0
|
||||||
? html`<p class="dim" style="padding:0.5rem 0">No tasks currently blocked.</p>`
|
? html`<p class="dim" style="padding:0.5rem 0">No tasks currently waiting.</p>`
|
||||||
: html`<div class="bt-list">${blockedTasks.map(t => {
|
: html`<div class="bt-list">${waitingTasks.map(t => {
|
||||||
const wsName = wsById[t.workstream_id]?.title ?? t.workstream_id?.slice(0,8) ?? "—";
|
const wsName = wsById[t.workstream_id]?.title ?? t.workstream_id?.slice(0,8) ?? "—";
|
||||||
return html`<div class="bt-row">
|
return html`<div class="bt-row">
|
||||||
<div class="bt-meta">${wsName}</div>
|
<div class="bt-meta">${wsName}</div>
|
||||||
@@ -415,11 +415,11 @@ const statusEl = html`<div>
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
statusEl.querySelector('[data-toggle="blocked-panel"]').addEventListener('click', () => {
|
statusEl.querySelector('[data-toggle="waiting-panel"]').addEventListener('click', () => {
|
||||||
const panel = statusEl.querySelector('#blocked-panel');
|
const panel = statusEl.querySelector('#waiting-panel');
|
||||||
const isOpen = panel.style.display !== 'none';
|
const isOpen = panel.style.display !== 'none';
|
||||||
panel.style.display = isOpen ? 'none' : 'block';
|
panel.style.display = isOpen ? 'none' : 'block';
|
||||||
statusEl.querySelector('[data-toggle="blocked-panel"] small').textContent =
|
statusEl.querySelector('[data-toggle="waiting-panel"] small').textContent =
|
||||||
isOpen ? `of ${tasks.total ?? 0} total · click to expand` : `of ${tasks.total ?? 0} total · click to collapse`;
|
isOpen ? `of ${tasks.total ?? 0} total · click to expand` : `of ${tasks.total ?? 0} total · click to collapse`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const _ts = interventionState.ts;
|
|||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]);
|
const OPEN_STATUSES = new Set(["wait", "todo", "progress"]);
|
||||||
// open = currently flagged for human action
|
// open = currently flagged for human action
|
||||||
// closed = previously flagged (intervention_note records the resolution comment)
|
// closed = previously flagged (intervention_note records the resolution comment)
|
||||||
const open = tasks.filter(t => t.needs_human === true);
|
const open = tasks.filter(t => t.needs_human === true);
|
||||||
@@ -125,7 +125,7 @@ Tasks flagged `needs_human=true` — actions only a human can take.
|
|||||||
import {openActionConfirm} from "./components/action-confirm.js";
|
import {openActionConfirm} from "./components/action-confirm.js";
|
||||||
|
|
||||||
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
|
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
|
||||||
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2};
|
const STATUS_ORDER = {wait: 0, progress: 1, todo: 2};
|
||||||
|
|
||||||
function sortTasks(arr) {
|
function sortTasks(arr) {
|
||||||
return [...arr].sort((a, b) => {
|
return [...arr].sort((a, b) => {
|
||||||
@@ -217,11 +217,11 @@ if (closed.length === 0) {
|
|||||||
.task-priority-medium { background: #dbeafe; color: #1e40af; }
|
.task-priority-medium { background: #dbeafe; color: #1e40af; }
|
||||||
.task-priority-low { background: #f1f5f9; color: #475569; }
|
.task-priority-low { background: #f1f5f9; color: #475569; }
|
||||||
.task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
|
.task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
|
||||||
.status-chip-blocked { background: #fee2e2; color: #991b1b; }
|
.status-chip-wait { background: #fef3c7; color: #92400e; }
|
||||||
.status-chip-in_progress { background: #dbeafe; color: #1e40af; }
|
.status-chip-progress { background: #ede9fe; color: #5b21b6; }
|
||||||
.status-chip-todo { background: #f1f5f9; color: #475569; }
|
.status-chip-todo { background: #f1f5f9; color: #475569; }
|
||||||
.status-chip-done { background: #dcfce7; color: #166534; }
|
.status-chip-done { background: #dcfce7; color: #166534; }
|
||||||
.status-chip-cancelled { background: #f1f5f9; color: #64748b; }
|
.status-chip-cancel { background: #f1f5f9; color: #64748b; }
|
||||||
.task-context { color: var(--theme-foreground-muted, #666); }
|
.task-context { color: var(--theme-foreground-muted, #666); }
|
||||||
.task-ws-name { font-style: italic; }
|
.task-ws-name { font-style: italic; }
|
||||||
.dim { color: gray; font-style: italic; }
|
.dim { color: gray; font-style: italic; }
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const _ts = taskState.ts;
|
|||||||
```js
|
```js
|
||||||
import {MultiSelect} from "./components/multiselect.js";
|
import {MultiSelect} from "./components/multiselect.js";
|
||||||
|
|
||||||
const STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"];
|
const STATUSES = ["wait", "todo", "progress", "done", "cancel"];
|
||||||
const PRIORITIES = ["critical", "high", "medium", "low"];
|
const PRIORITIES = ["critical", "high", "medium", "low"];
|
||||||
const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null);
|
const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null);
|
||||||
const DOMAINS = _domainsResp?.ok
|
const DOMAINS = _domainsResp?.ok
|
||||||
@@ -95,11 +95,11 @@ import {openEntityModal, buildEntityTable} from "./components/entity-modal.js";
|
|||||||
import {statusControl, TASK_STATUSES} from "./components/status-control.js";
|
import {statusControl, TASK_STATUSES} from "./components/status-control.js";
|
||||||
|
|
||||||
// ── KPI sidebar card ─────────────────────────────────────────────────────────
|
// ── KPI sidebar card ─────────────────────────────────────────────────────────
|
||||||
const _open = data.filter(t => ["todo", "in_progress", "blocked"].includes(t.status));
|
const _open = data.filter(t => ["wait", "todo", "progress"].includes(t.status));
|
||||||
const _blocked = data.filter(t => t.status === "blocked");
|
const _waiting = data.filter(t => t.status === "wait");
|
||||||
const _inProg = data.filter(t => t.status === "in_progress");
|
const _inProg = data.filter(t => t.status === "progress");
|
||||||
const _done = data.filter(t => t.status === "done");
|
const _done = data.filter(t => t.status === "done");
|
||||||
const _total = data.filter(t => t.status !== "cancelled").length;
|
const _total = data.filter(t => t.status !== "cancel").length;
|
||||||
const _donePct = _total > 0 ? Math.round(_done.length / _total * 100) : 0;
|
const _donePct = _total > 0 ? Math.round(_done.length / _total * 100) : 0;
|
||||||
|
|
||||||
const _kpiBox = html`<div class="kpi-infobox">
|
const _kpiBox = html`<div class="kpi-infobox">
|
||||||
@@ -111,13 +111,13 @@ const _kpiBox = html`<div class="kpi-infobox">
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-row">
|
<div class="kpi-row">
|
||||||
<span class="kpi-row-label">blocked</span>
|
<span class="kpi-row-label">waiting</span>
|
||||||
<div class="kpi-row-right">
|
<div class="kpi-row-right">
|
||||||
<div class="kpi-row-value" style="color:${_blocked.length > 0 ? '#dc2626' : 'inherit'}">${_blocked.length}</div>
|
<div class="kpi-row-value" style="color:${_waiting.length > 0 ? '#d97706' : 'inherit'}">${_waiting.length}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-row">
|
<div class="kpi-row">
|
||||||
<span class="kpi-row-label">in progress</span>
|
<span class="kpi-row-label">progress</span>
|
||||||
<div class="kpi-row-right">
|
<div class="kpi-row-right">
|
||||||
<div class="kpi-row-value">${_inProg.length}</div>
|
<div class="kpi-row-value">${_inProg.length}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,11 +154,11 @@ injectTocTop("live-indicator", _liveEl);
|
|||||||
import * as Plot from "npm:@observablehq/plot";
|
import * as Plot from "npm:@observablehq/plot";
|
||||||
|
|
||||||
const STATUS_COLOR = {
|
const STATUS_COLOR = {
|
||||||
|
wait: "#f59e0b",
|
||||||
todo: "#94a3b8",
|
todo: "#94a3b8",
|
||||||
in_progress: "#3b82f6",
|
progress: "#8b5cf6",
|
||||||
blocked: "#ef4444",
|
|
||||||
done: "#22c55e",
|
done: "#22c55e",
|
||||||
cancelled: "#cbd5e1",
|
cancel: "#cbd5e1",
|
||||||
};
|
};
|
||||||
|
|
||||||
const byStatus = STATUSES
|
const byStatus = STATUSES
|
||||||
@@ -178,16 +178,16 @@ display(byStatus.length === 0
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Blocked Tasks
|
## Waiting Tasks
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const _blockedInFilter = filtered.filter(t => t.status === "blocked");
|
const _waitingInFilter = filtered.filter(t => t.status === "wait");
|
||||||
|
|
||||||
if (_blockedInFilter.length === 0) {
|
if (_waitingInFilter.length === 0) {
|
||||||
display(html`<p class="dim">No blocked tasks in current filter. ✓</p>`);
|
display(html`<p class="dim">No waiting tasks in current filter. ✓</p>`);
|
||||||
} else {
|
} else {
|
||||||
display(html`<div class="task-blocked-list">${_blockedInFilter.map(t => html`
|
display(html`<div class="task-waiting-list">${_waitingInFilter.map(t => html`
|
||||||
<div class="task-blocked-item entity-row" onclick=${() => openEntityModal(t, "task")}>
|
<div class="task-waiting-item entity-row" onclick=${() => openEntityModal(t, "task")}>
|
||||||
<div class="task-item-header">
|
<div class="task-item-header">
|
||||||
<span class="task-badge task-priority-${t.priority}">${t.priority}</span>
|
<span class="task-badge task-priority-${t.priority}">${t.priority}</span>
|
||||||
<span class="task-context">${t.domain}</span>
|
<span class="task-context">${t.domain}</span>
|
||||||
@@ -196,7 +196,7 @@ if (_blockedInFilter.length === 0) {
|
|||||||
${t.assignee ? html`<span class="task-assignee">@${t.assignee}</span>` : ""}
|
${t.assignee ? html`<span class="task-assignee">@${t.assignee}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="task-title">${t.title}</div>
|
<div class="task-title">${t.title}</div>
|
||||||
${t.blocking_reason ? html`<div class="task-blocking-reason">⊘ ${t.blocking_reason}</div>` : ""}
|
${t.blocking_reason ? html`<div class="task-wait-reason">⊘ ${t.blocking_reason}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`)}</div>`);
|
`)}</div>`);
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ display(_filtersForm);
|
|||||||
display(html`<p><strong>${filtered.length}</strong> tasks shown.</p>`);
|
display(html`<p><strong>${filtered.length}</strong> tasks shown.</p>`);
|
||||||
|
|
||||||
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
|
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
|
||||||
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2, done: 3, cancelled: 4};
|
const STATUS_ORDER = {wait: 0, progress: 1, todo: 2, done: 3, cancel: 4};
|
||||||
|
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
const sd = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
|
const sd = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
|
||||||
@@ -246,9 +246,9 @@ display(buildEntityTable(
|
|||||||
|
|
||||||
/* ── Filters ──────────────────────────────────────────────────────────────── */
|
/* ── Filters ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* ── Blocked task cards ───────────────────────────────────────────────────── */
|
/* ── Waiting task cards ───────────────────────────────────────────────────── */
|
||||||
.task-blocked-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
.task-waiting-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.task-blocked-item { border-left: 3px solid #ef4444; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
|
.task-waiting-item { border-left: 3px solid #f59e0b; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
|
||||||
.task-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
|
.task-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
|
||||||
.task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
.task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
.task-priority-critical { background: #fee2e2; color: #991b1b; }
|
.task-priority-critical { background: #fee2e2; color: #991b1b; }
|
||||||
@@ -260,7 +260,7 @@ display(buildEntityTable(
|
|||||||
.task-due { color: #dc2626; font-weight: 600; }
|
.task-due { color: #dc2626; font-weight: 600; }
|
||||||
.task-assignee { color: var(--theme-foreground-muted, #888); }
|
.task-assignee { color: var(--theme-foreground-muted, #888); }
|
||||||
.task-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.15rem; }
|
.task-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.15rem; }
|
||||||
.task-blocking-reason { font-size: 0.8rem; color: #b45309; background: #fef3c7; border-radius: 4px; padding: 0.2rem 0.5rem; margin-top: 0.25rem; }
|
.task-wait-reason { font-size: 0.8rem; color: #b45309; background: #fef3c7; border-radius: 4px; padding: 0.2rem 0.5rem; margin-top: 0.25rem; }
|
||||||
|
|
||||||
/* ── Utility ──────────────────────────────────────────────────────────────── */
|
/* ── Utility ──────────────────────────────────────────────────────────────── */
|
||||||
.dim { color: gray; font-style: italic; }
|
.dim { color: gray; font-style: italic; }
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const _ts = todoState.ts;
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// ── Classify tasks ────────────────────────────────────────────────────────────
|
// ── Classify tasks ────────────────────────────────────────────────────────────
|
||||||
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]);
|
const OPEN_STATUSES = new Set(["wait", "todo", "progress"]);
|
||||||
|
|
||||||
// Internal: custodian domain, open, no [repo:] routing prefix
|
// Internal: custodian domain, open, no [repo:] routing prefix
|
||||||
const internal = tasks.filter(t =>
|
const internal = tasks.filter(t =>
|
||||||
@@ -141,7 +141,7 @@ without a cross-repo routing prefix.
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
|
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
|
||||||
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2};
|
const STATUS_ORDER = {wait: 0, progress: 1, todo: 2};
|
||||||
|
|
||||||
function sortTasks(arr) {
|
function sortTasks(arr) {
|
||||||
return [...arr].sort((a, b) => {
|
return [...arr].sort((a, b) => {
|
||||||
@@ -249,8 +249,8 @@ if (improvements.length === 0) {
|
|||||||
/* ── Task list ────────────────────────────────────────────────────────────── */
|
/* ── Task list ────────────────────────────────────────────────────────────── */
|
||||||
.task-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
.task-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.task-item { border-left: 3px solid var(--theme-foreground-faint, #ccc); border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
|
.task-item { border-left: 3px solid var(--theme-foreground-faint, #ccc); border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
|
||||||
.task-item.status-blocked { border-left-color: #ef4444; }
|
.task-item.status-wait { border-left-color: #f59e0b; }
|
||||||
.task-item.status-in_progress { border-left-color: #3b82f6; }
|
.task-item.status-progress { border-left-color: #8b5cf6; }
|
||||||
.task-item.status-todo { border-left-color: #94a3b8; }
|
.task-item.status-todo { border-left-color: #94a3b8; }
|
||||||
.task-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
|
.task-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
|
||||||
.task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
.task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
@@ -259,8 +259,8 @@ if (improvements.length === 0) {
|
|||||||
.task-priority-medium { background: #dbeafe; color: #1e40af; }
|
.task-priority-medium { background: #dbeafe; color: #1e40af; }
|
||||||
.task-priority-low { background: #f1f5f9; color: #475569; }
|
.task-priority-low { background: #f1f5f9; color: #475569; }
|
||||||
.task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
|
.task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
|
||||||
.status-chip-blocked { background: #fee2e2; color: #991b1b; }
|
.status-chip-wait { background: #fef3c7; color: #92400e; }
|
||||||
.status-chip-in_progress { background: #dbeafe; color: #1e40af; }
|
.status-chip-progress { background: #ede9fe; color: #5b21b6; }
|
||||||
.status-chip-todo { background: #f1f5f9; color: #475569; }
|
.status-chip-todo { background: #f1f5f9; color: #475569; }
|
||||||
.task-context { color: var(--theme-foreground-muted, #666); }
|
.task-context { color: var(--theme-foreground-muted, #666); }
|
||||||
.task-ws-name { font-style: italic; }
|
.task-ws-name { font-style: italic; }
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ if (raw.error) {
|
|||||||
<div><span>Tasks</span><strong>${taskRows.length}</strong></div>
|
<div><span>Tasks</span><strong>${taskRows.length}</strong></div>
|
||||||
</div>`);
|
</div>`);
|
||||||
|
|
||||||
const statusOrder = {blocked: 0, in_progress: 1, todo: 2, done: 3, cancelled: 4};
|
const statusOrder = {wait: 0, progress: 1, todo: 2, done: 3, cancel: 4};
|
||||||
const sortedTasks = [...taskRows].sort((a, b) => {
|
const sortedTasks = [...taskRows].sort((a, b) => {
|
||||||
const statusCompare = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
|
const statusCompare = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
|
||||||
if (statusCompare !== 0) return statusCompare;
|
if (statusCompare !== 0) return statusCompare;
|
||||||
@@ -131,8 +131,8 @@ if (raw.error) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.task-status-done { background: #e8f5e9; color: #1b5e20; }
|
.task-status-done { background: #e8f5e9; color: #1b5e20; }
|
||||||
.task-status-in_progress { background: #e3f2fd; color: #0d47a1; }
|
.task-status-progress { background: #ede9fe; color: #5b21b6; }
|
||||||
.task-status-blocked { background: #fff3e0; color: #bf360c; }
|
.task-status-wait { background: #fff3e0; color: #bf360c; }
|
||||||
.task-status-todo { background: #f1f5f9; color: #334155; }
|
.task-status-todo { background: #f1f5f9; color: #334155; }
|
||||||
.task-status-cancelled { background: #f3f4f6; color: #6b7280; }
|
.task-status-cancel { background: #f3f4f6; color: #6b7280; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function okResponse(overrides = {}) {
|
|||||||
target_id: "00000000-0000-0000-0000-000000000001",
|
target_id: "00000000-0000-0000-0000-000000000001",
|
||||||
actor: "dashboard",
|
actor: "dashboard",
|
||||||
current_status: "todo",
|
current_status: "todo",
|
||||||
target_status: "in_progress",
|
target_status: "progress",
|
||||||
file_backed: true,
|
file_backed: true,
|
||||||
archived_file: false,
|
archived_file: false,
|
||||||
task_linked: true,
|
task_linked: true,
|
||||||
@@ -102,23 +102,23 @@ test("status control posts dashboard changes through reconciliation", async () =
|
|||||||
const root = statusControl({
|
const root = statusControl({
|
||||||
entity,
|
entity,
|
||||||
type: "task",
|
type: "task",
|
||||||
statuses: ["todo", "in_progress"],
|
statuses: ["todo", "progress"],
|
||||||
onSaved: (updated, result) => {
|
onSaved: (updated, result) => {
|
||||||
saved = {updated, result};
|
saved = {updated, result};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [select, message] = root.children;
|
const [select, message] = root.children;
|
||||||
|
|
||||||
select.value = "in_progress";
|
select.value = "progress";
|
||||||
await select.listeners.change();
|
await select.listeners.change();
|
||||||
|
|
||||||
assert.equal(requests.length, 1);
|
assert.equal(requests.length, 1);
|
||||||
assert.equal(requests[0].url, "http://127.0.0.1:8000/reconciliation/state-change");
|
assert.equal(requests[0].url, "http://127.0.0.1:8000/reconciliation/state-change");
|
||||||
assert.equal(requests[0].body.target_type, "task");
|
assert.equal(requests[0].body.target_type, "task");
|
||||||
assert.equal(requests[0].body.target_status, "in_progress");
|
assert.equal(requests[0].body.target_status, "progress");
|
||||||
assert.equal(requests[0].body.expected_current_status, "todo");
|
assert.equal(requests[0].body.expected_current_status, "todo");
|
||||||
assert.equal(requests[0].body.apply, true);
|
assert.equal(requests[0].body.apply, true);
|
||||||
assert.equal(entity.status, "in_progress");
|
assert.equal(entity.status, "progress");
|
||||||
assert.equal(message.textContent, "synced");
|
assert.equal(message.textContent, "synced");
|
||||||
assert.equal(saved.result.write_through_result, "applied");
|
assert.equal(saved.result.write_through_result, "applied");
|
||||||
});
|
});
|
||||||
@@ -131,7 +131,7 @@ test("status control keeps local state on reconciliation conflicts", async () =>
|
|||||||
ok: true,
|
ok: true,
|
||||||
json: async () => okResponse({
|
json: async () => okResponse({
|
||||||
current_status: "done",
|
current_status: "done",
|
||||||
target_status: "in_progress",
|
target_status: "progress",
|
||||||
reconciliation_class: "deferred",
|
reconciliation_class: "deferred",
|
||||||
reason: "cached task status changed from expected 'todo' to 'done'",
|
reason: "cached task status changed from expected 'todo' to 'done'",
|
||||||
follow_up: "refresh the dashboard and retry the state change if it is still intended",
|
follow_up: "refresh the dashboard and retry the state change if it is still intended",
|
||||||
@@ -147,14 +147,14 @@ test("status control keeps local state on reconciliation conflicts", async () =>
|
|||||||
const root = statusControl({
|
const root = statusControl({
|
||||||
entity,
|
entity,
|
||||||
type: "task",
|
type: "task",
|
||||||
statuses: ["todo", "in_progress"],
|
statuses: ["todo", "progress"],
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
saved = true;
|
saved = true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [select, message] = root.children;
|
const [select, message] = root.children;
|
||||||
|
|
||||||
select.value = "in_progress";
|
select.value = "progress";
|
||||||
await select.listeners.change();
|
await select.listeners.change();
|
||||||
|
|
||||||
assert.equal(requests.length, 1);
|
assert.equal(requests.length, 1);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ state hub publishes:
|
|||||||
| `org.statehub.workstream.completed` | `api/routers/workstreams.py:update_workstream` (on transition) |
|
| `org.statehub.workstream.completed` | `api/routers/workstreams.py:update_workstream` (on transition) |
|
||||||
| `org.statehub.decision.resolved` | `api/routers/decisions.py:resolve_decision_action` |
|
| `org.statehub.decision.resolved` | `api/routers/decisions.py:resolve_decision_action` |
|
||||||
| `org.statehub.domain.goal.activated` | `api/routers/domain_goals.py:activate_domain_goal` |
|
| `org.statehub.domain.goal.activated` | `api/routers/domain_goals.py:activate_domain_goal` |
|
||||||
| `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` (per cancelled task) |
|
| `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` (per canceled task) |
|
||||||
|
|
||||||
All events use the shared `EventEnvelope` schema (`api/events/envelope.py`)
|
All events use the shared `EventEnvelope` schema (`api/events/envelope.py`)
|
||||||
and are published via `publish_event(subject, envelope)`. Publishing is
|
and are published via `publish_event(subject, envelope)`. Publishing is
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ Notes:
|
|||||||
# activity-definitions/state-hub-stale-task-cleanup.yaml
|
# activity-definitions/state-hub-stale-task-cleanup.yaml
|
||||||
id: the-custodian.state-hub-stale-task-cleanup
|
id: the-custodian.state-hub-stale-task-cleanup
|
||||||
description: |
|
description: |
|
||||||
Daily sweep that cancels tasks still 'todo|in_progress|blocked' inside
|
Daily sweep that cancels tasks still `wait|todo|progress` inside
|
||||||
finished or archived workstreams. Each cancellation also emits
|
finished or archived workstreams. Each cancellation also emits
|
||||||
org.statehub.task.stale on NATS for downstream reaction.
|
org.statehub.task.stale on NATS for downstream reaction.
|
||||||
trigger:
|
trigger:
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ exit_assertions:
|
|||||||
- id: tasks.all_done
|
- id: tasks.all_done
|
||||||
target: tasks.*.status
|
target: tasks.*.status
|
||||||
op: all_eq
|
op: all_eq
|
||||||
value: [done, cancelled]
|
value: [done, cancel]
|
||||||
description: All child tasks are done or cancelled.
|
description: All child tasks are done or canceled.
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema:
|
Schema:
|
||||||
@@ -127,7 +127,7 @@ exit_blocked: true
|
|||||||
blocking_assertions:
|
blocking_assertions:
|
||||||
- id: tasks.all_done
|
- id: tasks.all_done
|
||||||
passed: false
|
passed: false
|
||||||
reason: "Expected all values at tasks.*.status to be in ['done', 'cancelled']; got ['done', 'todo']."
|
reason: "Expected all values at tasks.*.status to be in ['done', 'cancel']; got ['done', 'todo']."
|
||||||
reachable:
|
reachable:
|
||||||
- ready
|
- ready
|
||||||
- active
|
- active
|
||||||
@@ -136,7 +136,7 @@ unreachable:
|
|||||||
blocking:
|
blocking:
|
||||||
id: tasks.all_done
|
id: tasks.all_done
|
||||||
passed: false
|
passed: false
|
||||||
reason: "Expected all values at tasks.*.status to be in ['done', 'cancelled']; got ['done', 'todo']."
|
reason: "Expected all values at tasks.*.status to be in ['done', 'cancel']; got ['done', 'todo']."
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema:
|
Schema:
|
||||||
@@ -152,13 +152,13 @@ Schema:
|
|||||||
### Workstreams
|
### Workstreams
|
||||||
|
|
||||||
Workstreams can express readiness for closure by asserting that child tasks
|
Workstreams can express readiness for closure by asserting that child tasks
|
||||||
are `done` or `cancelled`. They can express dependency blocking by checking that
|
are `done` or `cancel`. They can express dependency blocking by checking that
|
||||||
all dependency workstreams have reached `finished` or `archived`.
|
all dependency workstreams have reached `finished` or `archived`.
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
Tasks can express human intervention with the existing `needs_human` flag.
|
Tasks can express human intervention with the existing `needs_human` flag.
|
||||||
Returning from `blocked` to `in_progress` is an entry assertion over that same
|
Returning from `wait` to `progress` is an entry assertion over that same
|
||||||
flag. Lightweight completion remains unconstrained because curator intent is
|
flag. Lightweight completion remains unconstrained because curator intent is
|
||||||
the deciding signal.
|
the deciding signal.
|
||||||
|
|
||||||
|
|||||||
93
docs/task-state-attached-repo-impact.md
Normal file
93
docs/task-state-attached-repo-impact.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Task State Attached Repo Impact
|
||||||
|
|
||||||
|
Date: 2026-05-25
|
||||||
|
Interface change: `649102a2-4373-4621-9848-cc257e67c262`
|
||||||
|
Status: published
|
||||||
|
|
||||||
|
## Notice Channel
|
||||||
|
|
||||||
|
State Hub published a breaking schema interface change from `state-hub` to all
|
||||||
|
active attached repos except `state-hub` itself.
|
||||||
|
|
||||||
|
The notice points target repos to `docs/task-state-canon-migration.md` and asks
|
||||||
|
their local agents to update any State Hub task-status references:
|
||||||
|
|
||||||
|
```text
|
||||||
|
blocked -> wait
|
||||||
|
in_progress -> progress
|
||||||
|
cancelled/canceled -> cancel
|
||||||
|
todo -> todo
|
||||||
|
done -> done
|
||||||
|
```
|
||||||
|
|
||||||
|
Workstream/workplan lifecycle status is unchanged and still includes
|
||||||
|
`blocked`.
|
||||||
|
|
||||||
|
## Classification
|
||||||
|
|
||||||
|
Classification was based on `GET /repos/` plus a read-only local scan of active
|
||||||
|
repo paths. `legacy-literals` means a repo contains old words such as
|
||||||
|
`blocked`, `in_progress`, `cancelled`, `blocked_tasks`, or `TaskStatus`; the
|
||||||
|
hit can be task state, workstream state, capability/debt status, or prose, so
|
||||||
|
the owning repo agent must review context before editing.
|
||||||
|
|
||||||
|
| Repo | Local path | Classification |
|
||||||
|
|------|------------|----------------|
|
||||||
|
| activity-core | yes | file-backed; legacy-literals |
|
||||||
|
| artifact-store | yes | file-backed; legacy-literals |
|
||||||
|
| can-you-assist | yes | file-backed; legacy-literals |
|
||||||
|
| citation-engine | yes | general notice only |
|
||||||
|
| citation-evidence | yes | file-backed |
|
||||||
|
| citation-work | yes | general notice only |
|
||||||
|
| domain-tree | yes | file-backed; legacy-literals |
|
||||||
|
| evidence-anchor | yes | general notice only |
|
||||||
|
| evidence-binder | yes | general notice only |
|
||||||
|
| evidence-source | yes | general notice only |
|
||||||
|
| flex-auth | yes | file-backed; legacy-literals |
|
||||||
|
| guide-board | yes | file-backed; legacy-literals |
|
||||||
|
| helix-forge | yes | file-backed; legacy-literals |
|
||||||
|
| ihp-railiance-probe | yes | file-backed; legacy-literals |
|
||||||
|
| info-tech-canon | yes | canon source; file-backed |
|
||||||
|
| infospace-bench | yes | file-backed; legacy-literals |
|
||||||
|
| inter-hub | yes | file-backed; legacy-literals |
|
||||||
|
| issue-core | yes | file-backed; likely State Hub/task lifecycle client; legacy-literals |
|
||||||
|
| kaizen-agentic | no | general notice only |
|
||||||
|
| key-cape | yes | file-backed; legacy-literals |
|
||||||
|
| kontextual-engine | yes | file-backed; legacy-literals |
|
||||||
|
| llm-connect | yes | file-backed; legacy-literals |
|
||||||
|
| marki-docx | no | general notice only |
|
||||||
|
| markitect-filter | yes | file-backed; legacy-literals |
|
||||||
|
| markitect-project | yes | file-backed; legacy-literals |
|
||||||
|
| markitect-quarkdown | yes | file-backed; legacy-literals |
|
||||||
|
| markitect-tool | yes | file-backed; legacy-literals |
|
||||||
|
| net-kingdom | yes | file-backed; legacy-literals |
|
||||||
|
| open-cmis-tck | yes | file-backed; legacy-literals |
|
||||||
|
| open-reuse | yes | file-backed; legacy-literals |
|
||||||
|
| ops-bridge | yes | file-backed; legacy-literals |
|
||||||
|
| ops-warden | yes | file-backed; legacy-literals |
|
||||||
|
| phase-memory | yes | file-backed; legacy-literals |
|
||||||
|
| railiance-apps | yes | file-backed; legacy-literals |
|
||||||
|
| railiance-cluster | yes | file-backed; legacy-literals |
|
||||||
|
| railiance-enablement | yes | file-backed; legacy-literals |
|
||||||
|
| railiance-fabric | yes | file-backed; legacy-literals |
|
||||||
|
| railiance-hosts | yes | file-backed; legacy-literals |
|
||||||
|
| railiance-infra | yes | file-backed; legacy-literals |
|
||||||
|
| railiance-platform | yes | file-backed; legacy-literals |
|
||||||
|
| repo-scoping | yes | file-backed; legacy-literals |
|
||||||
|
| the-custodian | yes | file-backed; likely State Hub/task lifecycle client; legacy-literals |
|
||||||
|
| user-engine | yes | file-backed |
|
||||||
|
| vergabe-teilnahme | yes | file-backed; legacy-literals |
|
||||||
|
| vergabe_teilnahme | yes | file-backed; legacy-literals |
|
||||||
|
|
||||||
|
## Requested Repo-Agent Action
|
||||||
|
|
||||||
|
Each repo agent should:
|
||||||
|
|
||||||
|
1. Read its State Hub inbox notice for interface change
|
||||||
|
`649102a2-4373-4621-9848-cc257e67c262`.
|
||||||
|
2. Search local agent instructions, workplans, scripts, docs, tests,
|
||||||
|
dashboards, and API clients for State Hub task-status values.
|
||||||
|
3. Replace task-state usage with canonical values and leave unrelated
|
||||||
|
workstream/capability/debt statuses unchanged.
|
||||||
|
4. Report completion by replying in State Hub or resolving the interface change
|
||||||
|
for that repo once local adaptation is complete.
|
||||||
90
docs/task-state-canon-migration.md
Normal file
90
docs/task-state-canon-migration.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Task State Canon Migration
|
||||||
|
|
||||||
|
Date: 2026-05-25
|
||||||
|
Status: implementation source
|
||||||
|
|
||||||
|
## Canon Source
|
||||||
|
|
||||||
|
State Hub follows the task lifecycle defined by InfoTechCanon:
|
||||||
|
|
||||||
|
- `info-tech-canon/infospace/models/task/InfoTechCanonTaskModel.md`
|
||||||
|
- `info-tech-canon/seeds/InfoTechCanonTaskModel_RC1_seed.md`
|
||||||
|
|
||||||
|
The canon defines symbolic codes and lowercase stored values:
|
||||||
|
|
||||||
|
| Code | Stored value | Meaning |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `WAIT` | `wait` | Blocked, paused, or waiting on another actor, event, decision, input, or condition. |
|
||||||
|
| `TODO` | `todo` | Ready or planned work that is not actively being worked. |
|
||||||
|
| `PROG` | `progress` | Active work in focus. |
|
||||||
|
| `DONE` | `done` | Completed successfully. |
|
||||||
|
| `CNCL` | `cancel` | Stopped because the work is no longer relevant, wanted, or valid. |
|
||||||
|
|
||||||
|
State Hub stores lowercase values, not uppercase codes.
|
||||||
|
|
||||||
|
## State Hub Profile
|
||||||
|
|
||||||
|
State Hub task status is separate from workstream/workplan lifecycle status.
|
||||||
|
Workstream frontmatter continues to use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
proposed, ready, active, blocked, backlog, finished, archived
|
||||||
|
```
|
||||||
|
|
||||||
|
Task status now uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wait, todo, progress, done, cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
Compatibility aliases accepted during the migration window:
|
||||||
|
|
||||||
|
| Legacy value | Canon value |
|
||||||
|
|--------------|-------------|
|
||||||
|
| `blocked` | `wait` |
|
||||||
|
| `waiting` | `wait` |
|
||||||
|
| `in_progress` | `progress` |
|
||||||
|
| `cancelled` | `cancel` |
|
||||||
|
| `canceled` | `cancel` |
|
||||||
|
|
||||||
|
`done` and `cancel` are terminal for normal planning and completion checks.
|
||||||
|
`cancel` is final. `done` can be reopened only by an explicit workflow/profile
|
||||||
|
rule and should not be treated as normal progress churn.
|
||||||
|
|
||||||
|
## Wait Versus Human Intervention
|
||||||
|
|
||||||
|
`wait` is broader than the old `blocked` task status. It can mean blocked,
|
||||||
|
paused, waiting on another actor, waiting on an event, waiting on input, or
|
||||||
|
waiting on a decision.
|
||||||
|
|
||||||
|
State Hub keeps these fields separate:
|
||||||
|
|
||||||
|
- `status=wait`: the task is not actively progressing;
|
||||||
|
- `blocking_reason`: explanatory text about the wait condition;
|
||||||
|
- `needs_human=true`: the wait requires direct human action;
|
||||||
|
- `intervention_note`: human-facing instruction or resolution note.
|
||||||
|
|
||||||
|
A task can be `wait` without `needs_human=true`. A human intervention is
|
||||||
|
represented by `needs_human=true`, not by status alone.
|
||||||
|
|
||||||
|
## Attached Repo Adaptation Brief
|
||||||
|
|
||||||
|
Repos attached to State Hub should update local agent instructions, workplan
|
||||||
|
examples, scripts, docs, dashboards, and tests if they mention State Hub task
|
||||||
|
statuses.
|
||||||
|
|
||||||
|
Migration map:
|
||||||
|
|
||||||
|
```text
|
||||||
|
blocked -> wait
|
||||||
|
in_progress -> progress
|
||||||
|
cancelled -> cancel
|
||||||
|
canceled -> cancel
|
||||||
|
todo -> todo
|
||||||
|
done -> done
|
||||||
|
```
|
||||||
|
|
||||||
|
During the compatibility window, State Hub accepts legacy aliases in API and
|
||||||
|
workplan-file inputs, but writebacks prefer canonical values. Attached repos
|
||||||
|
should report completion by replying to their State Hub inbox message or by
|
||||||
|
closing the targeted `[repo:<slug>]` ecosystem task created for the adaptation.
|
||||||
@@ -16,8 +16,8 @@ This makes file-backed workplans harder to reason about. A workplan can be a
|
|||||||
good proposal but not ready to execute; a ready workplan can become stale after
|
good proposal but not ready to execute; a ready workplan can become stale after
|
||||||
the repo changes; parked backlog work should not clutter the current work view.
|
the repo changes; parked backlog work should not clutter the current work view.
|
||||||
|
|
||||||
Task status should remain separate. Tasks can keep using
|
Task status should remain separate. Tasks use the InfoTechCanon-aligned
|
||||||
`todo`, `in_progress`, `blocked`, `done`, and `cancelled`.
|
`wait`, `todo`, `progress`, `done`, and `cancel` lifecycle.
|
||||||
|
|
||||||
## Proposed Canonical Workplan States
|
## Proposed Canonical Workplan States
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
id: custodian.task.v1
|
id: custodian.task.v1
|
||||||
entity_type: task
|
entity_type: task
|
||||||
workstations:
|
workstations:
|
||||||
|
- name: wait
|
||||||
|
description: Task is waiting on another actor, event, decision, input, or condition.
|
||||||
|
entry_assertions: []
|
||||||
|
exit_assertions: []
|
||||||
- name: todo
|
- name: todo
|
||||||
description: Task is known but not currently underway.
|
description: Task is known but not currently underway.
|
||||||
entry_assertions: []
|
entry_assertions: []
|
||||||
exit_assertions: []
|
exit_assertions: []
|
||||||
- name: in_progress
|
- name: progress
|
||||||
description: Task is being actively worked.
|
description: Task is being actively worked.
|
||||||
entry_assertions:
|
entry_assertions:
|
||||||
- id: task.needs_human_false
|
- id: task.needs_human_false
|
||||||
@@ -19,25 +23,11 @@ workstations:
|
|||||||
op: all_eq
|
op: all_eq
|
||||||
value: false
|
value: false
|
||||||
description: Human intervention is not currently required.
|
description: Human intervention is not currently required.
|
||||||
- name: blocked
|
|
||||||
description: Task is blocked by a human decision or unavailable input.
|
|
||||||
entry_assertions:
|
|
||||||
- id: task.needs_human_true
|
|
||||||
target: needs_human
|
|
||||||
op: all_eq
|
|
||||||
value: true
|
|
||||||
description: The task requires human intervention.
|
|
||||||
exit_assertions:
|
|
||||||
- id: task.needs_human_false
|
|
||||||
target: needs_human
|
|
||||||
op: all_eq
|
|
||||||
value: false
|
|
||||||
description: Human intervention has been cleared.
|
|
||||||
- name: done
|
- name: done
|
||||||
description: Task is complete.
|
description: Task is complete.
|
||||||
entry_assertions: []
|
entry_assertions: []
|
||||||
exit_assertions: []
|
exit_assertions: []
|
||||||
- name: cancelled
|
- name: cancel
|
||||||
description: Task is intentionally not being completed.
|
description: Task is intentionally not being completed.
|
||||||
entry_assertions: []
|
entry_assertions: []
|
||||||
exit_assertions: []
|
exit_assertions: []
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ workstations:
|
|||||||
op: all_eq
|
op: all_eq
|
||||||
value:
|
value:
|
||||||
- done
|
- done
|
||||||
- cancelled
|
- cancel
|
||||||
description: All child tasks are done or cancelled.
|
description: All child tasks are done or canceled.
|
||||||
exit_assertions: []
|
exit_assertions: []
|
||||||
- name: archived
|
- name: archived
|
||||||
description: Closed work has been moved out of the active set.
|
description: Closed work has been moved out of the active set.
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ Do not use them as a substitute for formal work definition inside the domain rep
|
|||||||
| Tool | Key Args | When to use |
|
| Tool | Key Args | When to use |
|
||||||
|------|----------|-------------|
|
|------|----------|-------------|
|
||||||
| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. |
|
| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. |
|
||||||
| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, all blocked tasks, all open workstreams, last 20 events. Large (~10k tokens). |
|
| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, waiting tasks, all open workstreams, last 20 events. Large (~10k tokens). |
|
||||||
| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. |
|
| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. |
|
||||||
| `list_tasks(workstream_id, status?)` | `workstream_id`: UUID (required); `status?`: todo/in_progress/blocked/done/cancelled | List all tasks in a workstream. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. |
|
| `list_tasks(workstream_id, status?)` | `workstream_id`: UUID (required); `status?`: wait/todo/progress/done/cancel | List all tasks in a workstream. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. |
|
||||||
| `list_blocked_tasks(workstream_id?)` | optional filter | Surface all impediments, optionally scoped to one workstream. |
|
| `list_blocked_tasks(workstream_id?)` | optional filter | Legacy name: surfaces `wait` tasks, optionally scoped to one workstream. |
|
||||||
| `list_pending_decisions(topic_id?)` | optional filter | Decisions holding up work, sorted by deadline. |
|
| `list_pending_decisions(topic_id?)` | optional filter | Decisions holding up work, sorted by deadline. |
|
||||||
| `get_recent_progress(limit, since?)` | `limit` default 20; `since` ISO datetime | Reconstruct recent session history. |
|
| `get_recent_progress(limit, since?)` | `limit` default 20; `since` ISO datetime | Reconstruct recent session history. |
|
||||||
| `get_capability_profile(domain_slug?)` | `domain_slug`: optional domain slug | **Capability deep-dive.** Returns repos → capabilities tree for one domain or all active domains. Includes descriptions and keywords. For cross-domain architectural discussion or when a worker needs to understand what a domain provides without checking out its repos. |
|
| `get_capability_profile(domain_slug?)` | `domain_slug`: optional domain slug | **Capability deep-dive.** Returns repos → capabilities tree for one domain or all active domains. Includes descriptions and keywords. For cross-domain architectural discussion or when a worker needs to understand what a domain provides without checking out its repos. |
|
||||||
@@ -56,7 +56,7 @@ Do not use them as a substitute for formal work definition inside the domain rep
|
|||||||
|------|----------|-------|
|
|------|----------|-------|
|
||||||
| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. |
|
| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. |
|
||||||
| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. |
|
| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. |
|
||||||
| `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | |
|
| `update_task_status(task_id, status, ...)` | `status`: wait/todo/progress/done/cancel; `blocking_reason?` describes wait conditions | Legacy aliases `blocked`, `in_progress`, `cancelled`, and `canceled` are accepted during migration. |
|
||||||
| `update_workstream_status(workstream_id, status)` | `status`: proposed/ready/active/blocked/backlog/finished/archived | Thin shortcut — use `update_workstream` for full field control. |
|
| `update_workstream_status(workstream_id, status)` | `status`: proposed/ready/active/blocked/backlog/finished/archived | Thin shortcut — use `update_workstream` for full field control. |
|
||||||
| `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. |
|
| `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. |
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ Agents should call `record_token_event` (or pass `tokens_in`/`tokens_out` via
|
|||||||
| `state://topics` | Active topics list |
|
| `state://topics` | Active topics list |
|
||||||
| `state://workstreams/{topic_slug}` | Workstreams for a topic (by slug) |
|
| `state://workstreams/{topic_slug}` | Workstreams for a topic (by slug) |
|
||||||
| `state://decisions/blocking` | All pending decisions |
|
| `state://decisions/blocking` | All pending decisions |
|
||||||
| `state://tasks/blocked` | All blocked tasks |
|
| `state://tasks/blocked` | Legacy resource name; returns all `wait` tasks |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ def resource_blocking_decisions() -> str:
|
|||||||
|
|
||||||
@mcp.resource("state://tasks/blocked")
|
@mcp.resource("state://tasks/blocked")
|
||||||
def resource_blocked_tasks() -> str:
|
def resource_blocked_tasks() -> str:
|
||||||
"""All tasks with status=blocked."""
|
"""All tasks with status=wait. Legacy resource name kept for compatibility."""
|
||||||
return json.dumps(_get("/tasks", {"status": "blocked"}), indent=2)
|
return json.dumps(_get("/tasks", {"status": "wait"}), indent=2)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -142,7 +142,7 @@ def get_state_summary() -> str:
|
|||||||
"""Primary orientation tool. Call at the start of every session.
|
"""Primary orientation tool. Call at the start of every session.
|
||||||
|
|
||||||
Returns a full snapshot: topic/workstream/task/decision totals, blocking
|
Returns a full snapshot: topic/workstream/task/decision totals, blocking
|
||||||
decisions, blocked tasks, open workstreams, and the 20 most recent events.
|
decisions, waiting tasks, open workstreams, and the 20 most recent events.
|
||||||
|
|
||||||
NOTE: This response is large (~10k tokens). When working inside a single
|
NOTE: This response is large (~10k tokens). When working inside a single
|
||||||
registered domain repo, use get_domain_summary(domain_slug) instead —
|
registered domain repo, use get_domain_summary(domain_slug) instead —
|
||||||
@@ -315,7 +315,7 @@ def list_tasks(workstream_id: str, status: str | None = None) -> str:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
workstream_id: UUID of the workstream (required).
|
workstream_id: UUID of the workstream (required).
|
||||||
status: Optional filter — todo | in_progress | blocked | done | cancelled.
|
status: Optional filter — wait | todo | progress | done | cancel.
|
||||||
|
|
||||||
Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every
|
Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every
|
||||||
matching task. Use this to look up task UUIDs before calling update_task_status,
|
matching task. Use this to look up task UUIDs before calling update_task_status,
|
||||||
@@ -326,8 +326,8 @@ def list_tasks(workstream_id: str, status: str | None = None) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_blocked_tasks(workstream_id: str | None = None) -> str:
|
def list_blocked_tasks(workstream_id: str | None = None) -> str:
|
||||||
"""List all tasks with status=blocked, optionally filtered by workstream_id."""
|
"""List all waiting tasks, optionally filtered by workstream_id."""
|
||||||
return json.dumps(_get("/tasks", {"status": "blocked", "workstream_id": workstream_id}), indent=2)
|
return json.dumps(_get("/tasks", {"status": "wait", "workstream_id": workstream_id}), indent=2)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -512,7 +512,7 @@ def update_task_status(
|
|||||||
agent: Optional[str] = None,
|
agent: Optional[str] = None,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Update a task's status. blocking_reason is required when status='blocked'.
|
"""Update a task's status. Canonical status values are wait/todo/progress/done/cancel.
|
||||||
|
|
||||||
When status='done', always records a token event using the best available data:
|
When status='done', always records a token event using the best available data:
|
||||||
Tier 1 (best): pass tokens_in + tokens_out — exact counts from the session
|
Tier 1 (best): pass tokens_in + tokens_out — exact counts from the session
|
||||||
@@ -526,8 +526,8 @@ def update_task_status(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: UUID of the task
|
task_id: UUID of the task
|
||||||
status: todo | in_progress | blocked | done | cancelled
|
status: wait | todo | progress | done | cancel
|
||||||
blocking_reason: required when status=blocked
|
blocking_reason: optional wait-condition detail
|
||||||
tokens_in: exact input token count for this task (Tier 1)
|
tokens_in: exact input token count for this task (Tier 1)
|
||||||
tokens_out: exact output token count for this task (Tier 1)
|
tokens_out: exact output token count for this task (Tier 1)
|
||||||
workplan_tokens_in: total input tokens for the whole workplan (Tier 2)
|
workplan_tokens_in: total input tokens for the whole workplan (Tier 2)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def upgrade() -> None:
|
|||||||
"active", "blocked", "completed", "archived", name="workstreamstatus", create_type=True
|
"active", "blocked", "completed", "archived", name="workstreamstatus", create_type=True
|
||||||
)
|
)
|
||||||
task_status = postgresql.ENUM(
|
task_status = postgresql.ENUM(
|
||||||
"todo", "in_progress", "blocked", "done", "cancelled", name="taskstatus", create_type=True
|
"wait", "todo", "progress", "done", "cancel", name="taskstatus", create_type=True
|
||||||
)
|
)
|
||||||
task_priority = postgresql.ENUM(
|
task_priority = postgresql.ENUM(
|
||||||
"low", "medium", "high", "critical", name="taskpriority", create_type=True
|
"low", "medium", "high", "critical", name="taskpriority", create_type=True
|
||||||
|
|||||||
57
migrations/versions/a4v5w6x7y8z9_task_status_canon.py
Normal file
57
migrations/versions/a4v5w6x7y8z9_task_status_canon.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""adapt task status enum to InfoTechCanon task lifecycle
|
||||||
|
|
||||||
|
Revision ID: a4v5w6x7y8z9
|
||||||
|
Revises: z3u4v5w6x7y8
|
||||||
|
Create Date: 2026-05-25
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "a4v5w6x7y8z9"
|
||||||
|
down_revision = "z3u4v5w6x7y8"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE tasks ALTER COLUMN status DROP DEFAULT")
|
||||||
|
op.execute("ALTER TYPE taskstatus RENAME TO taskstatus_old")
|
||||||
|
op.execute("CREATE TYPE taskstatus AS ENUM ('wait', 'todo', 'progress', 'done', 'cancel')")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ALTER COLUMN status TYPE taskstatus
|
||||||
|
USING (
|
||||||
|
CASE status::text
|
||||||
|
WHEN 'blocked' THEN 'wait'
|
||||||
|
WHEN 'in_progress' THEN 'progress'
|
||||||
|
WHEN 'cancelled' THEN 'cancel'
|
||||||
|
ELSE status::text
|
||||||
|
END
|
||||||
|
)::taskstatus
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE tasks ALTER COLUMN status SET DEFAULT 'todo'")
|
||||||
|
op.execute("DROP TYPE taskstatus_old")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE tasks ALTER COLUMN status DROP DEFAULT")
|
||||||
|
op.execute("ALTER TYPE taskstatus RENAME TO taskstatus_new")
|
||||||
|
op.execute("CREATE TYPE taskstatus AS ENUM ('todo', 'in_progress', 'blocked', 'done', 'cancelled')")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ALTER COLUMN status TYPE taskstatus
|
||||||
|
USING (
|
||||||
|
CASE status::text
|
||||||
|
WHEN 'wait' THEN 'blocked'
|
||||||
|
WHEN 'progress' THEN 'in_progress'
|
||||||
|
WHEN 'cancel' THEN 'cancelled'
|
||||||
|
ELSE status::text
|
||||||
|
END
|
||||||
|
)::taskstatus
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE tasks ALTER COLUMN status SET DEFAULT 'todo'")
|
||||||
|
op.execute("DROP TYPE taskstatus_new")
|
||||||
@@ -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
|
Cron example: 0 3 * * * cd ~/state-hub && .venv/bin/python scripts/cleanup_stale_tasks.py
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 — ran successfully (zero or more tasks cancelled)
|
0 — ran successfully (zero or more tasks canceled)
|
||||||
1 — API unreachable or unexpected error
|
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__))))
|
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.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
||||||
|
from api.task_status import OPEN_TASK_STATUSES, normalize_task_status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from api.events import EventEnvelope, publish_event, shutdown_publisher
|
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]
|
shutdown_publisher = None # type: ignore[assignment]
|
||||||
|
|
||||||
API = "http://127.0.0.1:8000"
|
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)
|
CLOSED_WS_STATUS = set(CLOSED_WORKSTREAM_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ def main() -> int:
|
|||||||
|
|
||||||
stale = [
|
stale = [
|
||||||
t for t in tasks
|
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
|
and t["workstream_id"] in closed_ws
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -115,10 +116,10 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
patch(
|
patch(
|
||||||
f"/tasks/{t['id']}/",
|
f"/tasks/{t['id']}/",
|
||||||
{"status": "cancelled", "blocking_reason": reason},
|
{"status": "cancel", "blocking_reason": reason},
|
||||||
)
|
)
|
||||||
cancelled.append(t)
|
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:
|
if EventEnvelope is not None:
|
||||||
subject = "org.statehub.task.stale"
|
subject = "org.statehub.task.stale"
|
||||||
nats_events.append((
|
nats_events.append((
|
||||||
@@ -155,11 +156,11 @@ def main() -> int:
|
|||||||
by_ws.setdefault(closed_ws[t["workstream_id"]]["title"], []).append(t["title"])
|
by_ws.setdefault(closed_ws[t["workstream_id"]]["title"], []).append(t["title"])
|
||||||
|
|
||||||
summary = (
|
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)"
|
f"across {len(by_ws)} finished workstream(s)"
|
||||||
)
|
)
|
||||||
detail = {
|
detail = {
|
||||||
"cancelled_count": len(cancelled),
|
"canceled_count": len(cancelled),
|
||||||
"by_workstream": {ws: titles for ws, titles in by_ws.items()},
|
"by_workstream": {ws: titles for ws, titles in by_ws.items()},
|
||||||
"error_count": len(errors),
|
"error_count": len(errors),
|
||||||
}
|
}
|
||||||
@@ -173,7 +174,7 @@ def main() -> int:
|
|||||||
print(f"[cleanup-stale] Completed with {len(errors)} error(s).")
|
print(f"[cleanup-stale] Completed with {len(errors)} error(s).")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print(f"[cleanup-stale] Done. {len(cancelled)} task(s) cancelled.")
|
print(f"[cleanup-stale] Done. {len(cancelled)} task(s) canceled.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Checks:
|
|||||||
C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB
|
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-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-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:
|
Usage:
|
||||||
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
|
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,
|
ready_review_status,
|
||||||
)
|
)
|
||||||
from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402
|
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:
|
try:
|
||||||
import yaml as _yaml
|
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)$")
|
_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
|
||||||
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
|
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
|
||||||
SUPPORTED_WP_STATUSES = set(SUPPORTED_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_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||||
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
|
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
|
||||||
DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300"))
|
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)
|
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).
|
# 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] = dict(TASK_STATUS_ORDER)
|
||||||
STATUS_ORDER: dict[str, int] = {
|
|
||||||
"todo": 0,
|
|
||||||
"in_progress": 1,
|
|
||||||
"blocked": 1,
|
|
||||||
"done": 2,
|
|
||||||
"cancelled": 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str:
|
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)
|
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:
|
def canonical_workplan_filename(path: Path) -> str:
|
||||||
"""Return the workplan filename without an archive completion-date prefix."""
|
"""Return the workplan filename without an archive completion-date prefix."""
|
||||||
return _ARCHIVED_WP_RE.sub(r"\1", path.name)
|
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.",
|
invariant="Planning-state workplans cannot contain active task work.",
|
||||||
detection=(
|
detection=(
|
||||||
"Workplan status is proposed, ready, or backlog while a linked "
|
"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.",
|
repair="Patch the DB workstream and workplan frontmatter to status=active.",
|
||||||
test_anchor="tests/test_consistency_check.py::TestLifecycleRenormalization",
|
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('"')
|
t_sh_id = "" if _raw_sh is None else str(_raw_sh).strip().strip('"')
|
||||||
if t_sh_id in ("~", "null", "None", "none"):
|
if t_sh_id in ("~", "null", "None", "none"):
|
||||||
t_sh_id = ""
|
t_sh_id = ""
|
||||||
t_status = str(task.get("status", "")).strip()
|
t_status = normalise_task_status(task.get("status", "todo"))
|
||||||
|
|
||||||
if t_sh_id:
|
if t_sh_id:
|
||||||
file_task_sh_ids.add(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
|
continue
|
||||||
# C-10 / C-15: task status drift
|
# 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:
|
if t_status and db_t_status and t_status != db_t_status:
|
||||||
db_rank = STATUS_ORDER.get(db_t_status, 0)
|
db_rank = STATUS_ORDER.get(db_t_status, 0)
|
||||||
file_rank = STATUS_ORDER.get(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:
|
for db_t in db_tasks:
|
||||||
if db_t["id"] not in file_task_sh_ids:
|
if db_t["id"] not in file_task_sh_ids:
|
||||||
db_t_status = db_t.get("status", "")
|
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
|
# Auto-cancel fixable when workstream is finished and task is open
|
||||||
fixable = ws_finished and open_task
|
fixable = ws_finished and open_task
|
||||||
report.add(
|
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:
|
if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks:
|
||||||
non_terminal = [
|
non_terminal = [
|
||||||
t for t in db_tasks
|
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:
|
if not non_terminal:
|
||||||
report.add(
|
report.add(
|
||||||
severity="WARN", check_id="C-13",
|
severity="WARN", check_id="C-13",
|
||||||
message=(
|
message=(
|
||||||
f"All {len(db_tasks)} DB tasks for '{ws.get('slug')}' are "
|
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()"
|
f"worker likely forgot update_workstream_status()"
|
||||||
),
|
),
|
||||||
file_path=fname,
|
file_path=fname,
|
||||||
@@ -1358,8 +1365,8 @@ def _git_commit_writeback(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_BRIEF_HEADER = "<!-- custodian-brief: generated by fix-consistency — do not edit manually -->"
|
_BRIEF_HEADER = "<!-- custodian-brief: generated by fix-consistency — do not edit manually -->"
|
||||||
_TASK_STATUS_ICON = {"done": "✓", "cancelled": "✗", "in_progress": "►", "blocked": "!", "todo": "·"}
|
_TASK_STATUS_ICON = {"done": "✓", "cancel": "✗", "progress": "►", "wait": "!", "todo": "·"}
|
||||||
_OPEN_STATUSES = {"todo", "in_progress", "blocked"}
|
_OPEN_STATUSES = set(OPEN_TASK_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> bool:
|
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):
|
if not isinstance(tasks, list):
|
||||||
tasks = []
|
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)
|
total = len(tasks)
|
||||||
pct = f"{done}/{total}" if total else "no tasks"
|
pct = f"{done}/{total}" if total else "no tasks"
|
||||||
|
|
||||||
open_tasks = [t for t in tasks if t.get("status") in _OPEN_STATUSES]
|
open_tasks = [
|
||||||
# Show blocked first, then in_progress, then todo (cap at 5)
|
t for t in tasks
|
||||||
priority_order = {"blocked": 0, "in_progress": 1, "todo": 2}
|
if normalise_task_status(t.get("status", "todo")) in _OPEN_STATUSES
|
||||||
open_tasks.sort(key=lambda t: priority_order.get(t.get("status", "todo"), 9))
|
]
|
||||||
|
# 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 += [
|
lines += [
|
||||||
"",
|
"",
|
||||||
@@ -1448,14 +1465,14 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("**Open tasks:**")
|
lines.append("**Open tasks:**")
|
||||||
for t in open_tasks[:7]:
|
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"])
|
title = t.get("title", t["id"])
|
||||||
tid = t["id"]
|
tid = t["id"]
|
||||||
status = t.get("status", "")
|
|
||||||
blocker = t.get("blocking_reason", "")
|
blocker = t.get("blocking_reason", "")
|
||||||
task_line = f"- {icon} {title} `{tid[:8]}`"
|
task_line = f"- {icon} {title} `{tid[:8]}`"
|
||||||
if status == "blocked" and blocker:
|
if status == "wait" and blocker:
|
||||||
task_line += f"\n *(blocked: {blocker})*"
|
task_line += f"\n *(wait: {blocker})*"
|
||||||
lines.append(task_line)
|
lines.append(task_line)
|
||||||
if len(open_tasks) > 7:
|
if len(open_tasks) > 7:
|
||||||
lines.append(f"- … and {len(open_tasks) - 7} more open tasks")
|
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()
|
t_id = str(task.get("id", "")).strip()
|
||||||
if not t_id:
|
if not t_id:
|
||||||
continue
|
continue
|
||||||
t_status = str(task.get("status", "todo")).strip()
|
t_status = normalise_task_status(task.get("status", "todo"))
|
||||||
if t_status not in VALID_TASK_STATUSES:
|
|
||||||
t_status = "todo"
|
|
||||||
t_priority = str(task.get("priority", "medium")).strip()
|
t_priority = str(task.get("priority", "medium")).strip()
|
||||||
if t_priority not in VALID_TASK_PRIORITIES:
|
if t_priority not in VALID_TASK_PRIORITIES:
|
||||||
t_priority = "medium"
|
t_priority = "medium"
|
||||||
@@ -1767,9 +1782,7 @@ def fix_repo(
|
|||||||
f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created"
|
f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
t_status = str(task.get("status", "todo")).strip()
|
t_status = normalise_task_status(task.get("status", "todo"))
|
||||||
if t_status not in VALID_TASK_STATUSES:
|
|
||||||
t_status = "todo"
|
|
||||||
t_priority = str(task.get("priority", "medium")).strip()
|
t_priority = str(task.get("priority", "medium")).strip()
|
||||||
if t_priority not in VALID_TASK_PRIORITIES:
|
if t_priority not in VALID_TASK_PRIORITIES:
|
||||||
t_priority = "medium"
|
t_priority = "medium"
|
||||||
@@ -1795,10 +1808,10 @@ def fix_repo(
|
|||||||
elif issue.check_id == "C-12":
|
elif issue.check_id == "C-12":
|
||||||
task_id = ctx["task_id"]
|
task_id = ctx["task_id"]
|
||||||
if ctx.get("ws_finished"):
|
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:
|
if result is not None:
|
||||||
report.fixes_applied.append(
|
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":
|
elif issue.check_id == "C-22":
|
||||||
@@ -2013,7 +2026,7 @@ def archive_closed_workplans(
|
|||||||
tasks = get_tasks_from_workplan(meta, body)
|
tasks = get_tasks_from_workplan(meta, body)
|
||||||
open_tasks = [
|
open_tasks = [
|
||||||
t for t in 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:
|
if open_tasks:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
|
|||||||
```bash
|
```bash
|
||||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"status": "in_progress"}'
|
-d '{"status": "progress"}'
|
||||||
# values: todo | in_progress | done | blocked
|
# values: wait | todo | progress | done | cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Flag a task for human review
|
### 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)
|
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
|
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
|
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:**
|
**During work:**
|
||||||
- Update task statuses in workplan files as tasks progress
|
- Update task statuses in workplan files as tasks progress
|
||||||
@@ -146,7 +146,7 @@ derived health labels, not frontmatter statuses.
|
|||||||
|
|
||||||
` ` `task
|
` ` `task
|
||||||
id: {WP_PREFIX}-NNNN-T01
|
id: {WP_PREFIX}-NNNN-T01
|
||||||
status: todo | in_progress | done | blocked
|
status: wait | todo | progress | done | cancel
|
||||||
priority: high | medium | low
|
priority: high | medium | low
|
||||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
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.
|
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:
|
To create a new workplan:
|
||||||
1. Write the file following the format above
|
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/
|
ls workplans/
|
||||||
```
|
```
|
||||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||||
`todo`/`in_progress` tasks.
|
`wait`/`todo`/`progress` tasks.
|
||||||
|
|
||||||
**Step 4 — Present brief**
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from api.workplan_status import ( # noqa: E402
|
|||||||
SUPPORTED_WORKSTREAM_STATUSES,
|
SUPPORTED_WORKSTREAM_STATUSES,
|
||||||
normalize_workstream_status,
|
normalize_workstream_status,
|
||||||
)
|
)
|
||||||
|
from api.task_status import CANONICAL_TASK_STATUSES, normalize_task_status # noqa: E402
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml as _yaml
|
import yaml as _yaml
|
||||||
@@ -70,7 +71,7 @@ except ImportError:
|
|||||||
REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"}
|
REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"}
|
||||||
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
|
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
|
||||||
SUPPORTED_WP_STATUSES = set(SUPPORTED_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_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||||
|
|
||||||
_WP_ID_RE = re.compile(r"^(?:[A-Z]+-WP-\d+|ADHOC-\d{4}-\d{2}-\d{2})$")
|
_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", ""))
|
t_status = str(task.get("status", ""))
|
||||||
if not t_status:
|
if not t_status:
|
||||||
report.add(Level.FAIL, "task-status", "Missing 'status' field", tref)
|
report.add(Level.FAIL, "task-status", "Missing 'status' field", tref)
|
||||||
elif t_status not in VALID_TASK_STATUSES:
|
else:
|
||||||
report.add(Level.FAIL, "task-status-value",
|
try:
|
||||||
f"status {t_status!r} not in {sorted(VALID_TASK_STATUSES)}", tref)
|
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", ""))
|
t_prio = str(task.get("priority", ""))
|
||||||
if not t_prio:
|
if not t_prio:
|
||||||
report.add(Level.WARN, "task-priority", "Missing 'priority' field", tref)
|
report.add(Level.WARN, "task-priority", "Missing 'priority' field", tref)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async def _create_workstream(client, topic_id):
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def _create_task(client, workstream_id, title="Test task", status="blocked"):
|
async def _create_task(client, workstream_id, title="Test task", status="wait"):
|
||||||
r = await client.post("/tasks/", json={
|
r = await client.post("/tasks/", json={
|
||||||
"workstream_id": workstream_id, "title": title,
|
"workstream_id": workstream_id, "title": title,
|
||||||
})
|
})
|
||||||
@@ -43,7 +43,7 @@ async def _create_task(client, workstream_id, title="Test task", status="blocked
|
|||||||
task = r.json()
|
task = r.json()
|
||||||
if status != "todo":
|
if status != "todo":
|
||||||
patch = {"status": status}
|
patch = {"status": status}
|
||||||
if status == "blocked":
|
if status == "wait":
|
||||||
patch["blocking_reason"] = "Waiting for capability request"
|
patch["blocking_reason"] = "Waiting for capability request"
|
||||||
r2 = await client.patch(f"/tasks/{task['id']}", json=patch)
|
r2 = await client.patch(f"/tasks/{task['id']}", json=patch)
|
||||||
assert r2.status_code == 200, r2.text
|
assert r2.status_code == 200, r2.text
|
||||||
@@ -229,7 +229,7 @@ class TestCapabilityRequestLifecycle:
|
|||||||
await _setup_two_domains(client)
|
await _setup_two_domains(client)
|
||||||
topic = await _create_topic(client, "custodian")
|
topic = await _create_topic(client, "custodian")
|
||||||
ws = await _create_workstream(client, topic["id"])
|
ws = await _create_workstream(client, topic["id"])
|
||||||
task = await _create_task(client, ws["id"], status="blocked")
|
task = await _create_task(client, ws["id"], status="wait")
|
||||||
|
|
||||||
req = await _create_request(client, blocking_task_id=task["id"])
|
req = await _create_request(client, blocking_task_id=task["id"])
|
||||||
|
|
||||||
|
|||||||
@@ -558,11 +558,11 @@ class TestStatusOrder:
|
|||||||
def test_todo_is_lowest(self):
|
def test_todo_is_lowest(self):
|
||||||
assert STATUS_ORDER["todo"] == 0
|
assert STATUS_ORDER["todo"] == 0
|
||||||
|
|
||||||
def test_done_and_cancelled_are_highest(self):
|
def test_done_and_cancel_are_highest(self):
|
||||||
assert STATUS_ORDER["done"] == STATUS_ORDER["cancelled"] == 2
|
assert STATUS_ORDER["done"] == STATUS_ORDER["cancel"] == 2
|
||||||
|
|
||||||
def test_in_progress_and_blocked_are_mid(self):
|
def test_progress_and_wait_are_mid(self):
|
||||||
assert STATUS_ORDER["in_progress"] == STATUS_ORDER["blocked"] == 1
|
assert STATUS_ORDER["progress"] == STATUS_ORDER["wait"] == 1
|
||||||
|
|
||||||
def test_db_ahead_detected(self):
|
def test_db_ahead_detected(self):
|
||||||
"""done (DB) vs todo (file) — DB is ahead."""
|
"""done (DB) vs todo (file) — DB is ahead."""
|
||||||
@@ -573,8 +573,8 @@ class TestStatusOrder:
|
|||||||
assert STATUS_ORDER["todo"] < STATUS_ORDER["done"]
|
assert STATUS_ORDER["todo"] < STATUS_ORDER["done"]
|
||||||
|
|
||||||
def test_same_rank_treated_as_db_ahead(self):
|
def test_same_rank_treated_as_db_ahead(self):
|
||||||
"""in_progress (DB) vs blocked (file) — same rank, no regression."""
|
"""progress (DB) vs wait (file) — same rank, no regression."""
|
||||||
assert STATUS_ORDER["in_progress"] >= STATUS_ORDER["blocked"]
|
assert STATUS_ORDER["progress"] >= STATUS_ORDER["wait"]
|
||||||
|
|
||||||
def test_todo_to_done_is_regression(self):
|
def test_todo_to_done_is_regression(self):
|
||||||
"""Applying file=todo to DB=done would be a regression."""
|
"""Applying file=todo to DB=done would be a regression."""
|
||||||
@@ -718,7 +718,7 @@ class TestPatchTaskStatusInFile:
|
|||||||
content = (
|
content = (
|
||||||
"---\nid: WP-001\n---\n"
|
"---\nid: WP-001\n---\n"
|
||||||
"```task\nid: T01\nstatus: todo\n```\n"
|
"```task\nid: T01\nstatus: todo\n```\n"
|
||||||
"```task\nid: T02\nstatus: in_progress\n```\n"
|
"```task\nid: T02\nstatus: progress\n```\n"
|
||||||
)
|
)
|
||||||
f = self._make_workplan(tmp_path, content)
|
f = self._make_workplan(tmp_path, content)
|
||||||
_patch_task_status_in_file(f, "T02", "done")
|
_patch_task_status_in_file(f, "T02", "done")
|
||||||
@@ -783,7 +783,7 @@ class TestLifecycleRenormalization:
|
|||||||
"## Implement Demo\n\n"
|
"## Implement Demo\n\n"
|
||||||
"```task\n"
|
"```task\n"
|
||||||
"id: STATE-WP-0001-T01\n"
|
"id: STATE-WP-0001-T01\n"
|
||||||
"status: in_progress\n"
|
"status: progress\n"
|
||||||
"priority: high\n"
|
"priority: high\n"
|
||||||
"state_hub_task_id: \"task-1\"\n"
|
"state_hub_task_id: \"task-1\"\n"
|
||||||
"```\n",
|
"```\n",
|
||||||
@@ -805,7 +805,7 @@ class TestLifecycleRenormalization:
|
|||||||
task = {
|
task = {
|
||||||
"id": "task-1",
|
"id": "task-1",
|
||||||
"title": "Implement Demo",
|
"title": "Implement Demo",
|
||||||
"status": "in_progress",
|
"status": "progress",
|
||||||
"description": None,
|
"description": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from api.services.lifecycle import (
|
|||||||
def test_task_start_activates_planning_parent(parent_status):
|
def test_task_start_activates_planning_parent(parent_status):
|
||||||
assert should_activate_parent_for_task_start(
|
assert should_activate_parent_for_task_start(
|
||||||
previous_task_status="todo",
|
previous_task_status="todo",
|
||||||
new_task_status="in_progress",
|
new_task_status="progress",
|
||||||
parent_workstream_status=parent_status,
|
parent_workstream_status=parent_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,15 +25,15 @@ def test_task_start_activates_planning_parent(parent_status):
|
|||||||
def test_task_start_does_not_rewrite_non_planning_parent(parent_status):
|
def test_task_start_does_not_rewrite_non_planning_parent(parent_status):
|
||||||
assert not should_activate_parent_for_task_start(
|
assert not should_activate_parent_for_task_start(
|
||||||
previous_task_status="todo",
|
previous_task_status="todo",
|
||||||
new_task_status="in_progress",
|
new_task_status="progress",
|
||||||
parent_workstream_status=parent_status,
|
parent_workstream_status=parent_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_task_start_requires_todo_to_in_progress_transition():
|
def test_task_start_requires_todo_to_progress_transition():
|
||||||
assert not should_activate_parent_for_task_start(
|
assert not should_activate_parent_for_task_start(
|
||||||
previous_task_status="in_progress",
|
previous_task_status="progress",
|
||||||
new_task_status="in_progress",
|
new_task_status="progress",
|
||||||
parent_workstream_status="ready",
|
parent_workstream_status="ready",
|
||||||
)
|
)
|
||||||
assert not should_activate_parent_for_task_start(
|
assert not should_activate_parent_for_task_start(
|
||||||
@@ -44,22 +44,22 @@ def test_task_start_requires_todo_to_in_progress_transition():
|
|||||||
|
|
||||||
|
|
||||||
def test_has_active_task_status_ignores_terminal_and_todo_statuses():
|
def test_has_active_task_status_ignores_terminal_and_todo_statuses():
|
||||||
assert has_active_task_status(["todo", "done", "cancelled"]) is False
|
assert has_active_task_status(["todo", "done", "cancel"]) is False
|
||||||
assert has_active_task_status(["todo", "blocked"]) is True
|
assert has_active_task_status(["todo", "wait"]) is True
|
||||||
assert has_active_task_status(["in_progress"]) is True
|
assert has_active_task_status(["progress"]) is True
|
||||||
|
|
||||||
|
|
||||||
def test_active_task_state_activates_planning_parent_for_renormalization():
|
def test_active_task_state_activates_planning_parent_for_renormalization():
|
||||||
assert should_activate_parent_for_active_tasks(
|
assert should_activate_parent_for_active_tasks(
|
||||||
parent_workstream_status="proposed",
|
parent_workstream_status="proposed",
|
||||||
task_statuses=["todo", "in_progress"],
|
task_statuses=["todo", "progress"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_active_task_state_does_not_unblock_blocked_parent():
|
def test_active_task_state_does_not_unblock_blocked_parent():
|
||||||
assert not should_activate_parent_for_active_tasks(
|
assert not should_activate_parent_for_active_tasks(
|
||||||
parent_workstream_status="blocked",
|
parent_workstream_status="blocked",
|
||||||
task_statuses=["in_progress"],
|
task_statuses=["progress"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ def test_status_value_unwraps_enum_like_values():
|
|||||||
class Status:
|
class Status:
|
||||||
value = "In_Progress"
|
value = "In_Progress"
|
||||||
|
|
||||||
assert status_value(Status()) == "in_progress"
|
assert status_value(Status()) == "progress"
|
||||||
|
|
||||||
|
|
||||||
def test_transition_workstream_status_normalizes_aliases():
|
def test_transition_workstream_status_normalizes_aliases():
|
||||||
@@ -92,10 +92,10 @@ def test_transition_task_status_activates_parent_once():
|
|||||||
|
|
||||||
task = Task()
|
task = Task()
|
||||||
ws = Workstream()
|
ws = Workstream()
|
||||||
result = transition_task_status(task, "in_progress", parent_workstream=ws)
|
result = transition_task_status(task, "progress", parent_workstream=ws)
|
||||||
|
|
||||||
assert task.status == "in_progress"
|
assert task.status == "progress"
|
||||||
assert ws.status == "active"
|
assert ws.status == "active"
|
||||||
assert result.parent_activated is True
|
assert result.parent_activated is True
|
||||||
assert result.previous_status == "todo"
|
assert result.previous_status == "todo"
|
||||||
assert result.target_status == "in_progress"
|
assert result.target_status == "progress"
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def test_archived_workstream_file_requires_confirmation():
|
|||||||
def test_task_status_update_writes_through_to_task_block():
|
def test_task_status_update_writes_through_to_task_block():
|
||||||
result = classify_task_status_change(
|
result = classify_task_status_change(
|
||||||
current_status="todo",
|
current_status="todo",
|
||||||
target_status="in_progress",
|
target_status="progress",
|
||||||
file_backed=True,
|
file_backed=True,
|
||||||
task_linked=True,
|
task_linked=True,
|
||||||
)
|
)
|
||||||
@@ -80,7 +80,7 @@ def test_task_status_update_writes_through_to_task_block():
|
|||||||
def test_task_without_file_is_deferred():
|
def test_task_without_file_is_deferred():
|
||||||
result = classify_task_status_change(
|
result = classify_task_status_change(
|
||||||
current_status="todo",
|
current_status="todo",
|
||||||
target_status="in_progress",
|
target_status="progress",
|
||||||
file_backed=False,
|
file_backed=False,
|
||||||
task_linked=True,
|
task_linked=True,
|
||||||
)
|
)
|
||||||
@@ -92,7 +92,7 @@ def test_task_without_file_is_deferred():
|
|||||||
def test_unlinked_task_is_deferred_until_link_repaired():
|
def test_unlinked_task_is_deferred_until_link_repaired():
|
||||||
result = classify_task_status_change(
|
result = classify_task_status_change(
|
||||||
current_status="todo",
|
current_status="todo",
|
||||||
target_status="in_progress",
|
target_status="progress",
|
||||||
file_backed=True,
|
file_backed=True,
|
||||||
task_linked=False,
|
task_linked=False,
|
||||||
)
|
)
|
||||||
@@ -101,22 +101,22 @@ def test_unlinked_task_is_deferred_until_link_repaired():
|
|||||||
assert "not linked" in result.reason
|
assert "not linked" in result.reason
|
||||||
|
|
||||||
|
|
||||||
def test_blocked_task_requires_blocking_reason():
|
def test_wait_task_requires_blocking_reason():
|
||||||
result = classify_task_status_change(
|
result = classify_task_status_change(
|
||||||
current_status="todo",
|
current_status="todo",
|
||||||
target_status="blocked",
|
target_status="wait",
|
||||||
file_backed=True,
|
file_backed=True,
|
||||||
task_linked=True,
|
task_linked=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION
|
assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION
|
||||||
assert "blocking reason" in result.reason
|
assert "wait condition" in result.reason
|
||||||
|
|
||||||
|
|
||||||
def test_blocked_task_with_reason_writes_through():
|
def test_wait_task_with_reason_writes_through():
|
||||||
result = classify_task_status_change(
|
result = classify_task_status_change(
|
||||||
current_status="todo",
|
current_status="todo",
|
||||||
target_status="blocked",
|
target_status="wait",
|
||||||
file_backed=True,
|
file_backed=True,
|
||||||
task_linked=True,
|
task_linked=True,
|
||||||
blocking_reason="Waiting on dependency",
|
blocking_reason="Waiting on dependency",
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ class TestTasks:
|
|||||||
|
|
||||||
r = await client.delete(f"/tasks/{task['id']}")
|
r = await client.delete(f"/tasks/{task['id']}")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["status"] == "cancelled"
|
assert r.json()["status"] == "cancel"
|
||||||
|
|
||||||
async def test_filter_by_priority(self, client):
|
async def test_filter_by_priority(self, client):
|
||||||
await _create_domain(client)
|
await _create_domain(client)
|
||||||
@@ -238,7 +238,7 @@ class TestTasks:
|
|||||||
)
|
)
|
||||||
task = await _create_task(client, ws["id"])
|
task = await _create_task(client, ws["id"])
|
||||||
|
|
||||||
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
|
r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
r = await client.get(f"/workstreams/{ws['id']}")
|
r = await client.get(f"/workstreams/{ws['id']}")
|
||||||
@@ -251,7 +251,7 @@ class TestTasks:
|
|||||||
ws = await _create_workstream(client, topic["id"], slug="blocked-ws", status="blocked")
|
ws = await _create_workstream(client, topic["id"], slug="blocked-ws", status="blocked")
|
||||||
task = await _create_task(client, ws["id"])
|
task = await _create_task(client, ws["id"])
|
||||||
|
|
||||||
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
|
r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
r = await client.get(f"/workstreams/{ws['id']}")
|
r = await client.get(f"/workstreams/{ws['id']}")
|
||||||
@@ -326,13 +326,13 @@ class TestStateSummary:
|
|||||||
topic = await _create_topic(client)
|
topic = await _create_topic(client)
|
||||||
ws = await _create_workstream(client, topic["id"])
|
ws = await _create_workstream(client, topic["id"])
|
||||||
task = await _create_task(client, ws["id"])
|
task = await _create_task(client, ws["id"])
|
||||||
# Mark as blocked so it shows in blocked_tasks
|
# Mark as wait so it shows in waiting_tasks
|
||||||
await client.patch(f"/tasks/{task['id']}",
|
await client.patch(f"/tasks/{task['id']}",
|
||||||
json={"status": "blocked", "blocking_reason": "waiting on dep"})
|
json={"status": "wait", "blocking_reason": "waiting on dep"})
|
||||||
|
|
||||||
r = await client.get("/state/summary")
|
r = await client.get("/state/summary")
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert len(body["blocked_tasks"]) >= 1
|
assert len(body["waiting_tasks"]) >= 1
|
||||||
|
|
||||||
async def test_summary_derives_blocked_workstream_from_flow_engine(self, client):
|
async def test_summary_derives_blocked_workstream_from_flow_engine(self, client):
|
||||||
await _create_domain(client)
|
await _create_domain(client)
|
||||||
@@ -449,7 +449,7 @@ class TestReconciliationEndpoints:
|
|||||||
assert body["reconciliation_class"] == "human_confirmation"
|
assert body["reconciliation_class"] == "human_confirmation"
|
||||||
assert "open work" in body["reason"]
|
assert "open work" in body["reason"]
|
||||||
|
|
||||||
async def test_classify_task_blocked_without_reason_needs_confirmation(self, client):
|
async def test_classify_task_wait_without_reason_needs_confirmation(self, client):
|
||||||
await _create_domain(client)
|
await _create_domain(client)
|
||||||
topic = await _create_topic(client)
|
topic = await _create_topic(client)
|
||||||
ws = await _create_workstream(client, topic["id"])
|
ws = await _create_workstream(client, topic["id"])
|
||||||
@@ -458,7 +458,7 @@ class TestReconciliationEndpoints:
|
|||||||
r = await client.post("/reconciliation/state-change", json={
|
r = await client.post("/reconciliation/state-change", json={
|
||||||
"target_type": "task",
|
"target_type": "task",
|
||||||
"target_id": task["id"],
|
"target_id": task["id"],
|
||||||
"target_status": "blocked",
|
"target_status": "wait",
|
||||||
"file_backed": True,
|
"file_backed": True,
|
||||||
"task_linked": True,
|
"task_linked": True,
|
||||||
})
|
})
|
||||||
@@ -467,7 +467,7 @@ class TestReconciliationEndpoints:
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["current_status"] == "todo"
|
assert body["current_status"] == "todo"
|
||||||
assert body["reconciliation_class"] == "human_confirmation"
|
assert body["reconciliation_class"] == "human_confirmation"
|
||||||
assert "blocking reason" in body["reason"]
|
assert "wait condition" in body["reason"]
|
||||||
|
|
||||||
async def test_classify_unknown_workstream_returns_404(self, client):
|
async def test_classify_unknown_workstream_returns_404(self, client):
|
||||||
r = await client.post("/reconciliation/state-change", json={
|
r = await client.post("/reconciliation/state-change", json={
|
||||||
@@ -582,7 +582,7 @@ class TestReconciliationEndpoints:
|
|||||||
r = await client.post("/reconciliation/state-change", json={
|
r = await client.post("/reconciliation/state-change", json={
|
||||||
"target_type": "task",
|
"target_type": "task",
|
||||||
"target_id": task["id"],
|
"target_id": task["id"],
|
||||||
"target_status": "in_progress",
|
"target_status": "progress",
|
||||||
"actor": "dashboard",
|
"actor": "dashboard",
|
||||||
"apply": True,
|
"apply": True,
|
||||||
})
|
})
|
||||||
@@ -592,10 +592,10 @@ class TestReconciliationEndpoints:
|
|||||||
assert body["reconciliation_class"] == "write_through"
|
assert body["reconciliation_class"] == "write_through"
|
||||||
assert body["write_through_result"] == "applied"
|
assert body["write_through_result"] == "applied"
|
||||||
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
|
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
|
||||||
assert "status: in_progress" in wp.read_text(encoding="utf-8")
|
assert "status: progress" in wp.read_text(encoding="utf-8")
|
||||||
|
|
||||||
r = await client.get(f"/tasks/{task['id']}")
|
r = await client.get(f"/tasks/{task['id']}")
|
||||||
assert r.json()["status"] == "in_progress"
|
assert r.json()["status"] == "progress"
|
||||||
|
|
||||||
async def test_apply_task_start_write_through_activates_parent_file_and_db(self, client, tmp_path):
|
async def test_apply_task_start_write_through_activates_parent_file_and_db(self, client, tmp_path):
|
||||||
await _create_domain(client)
|
await _create_domain(client)
|
||||||
@@ -630,7 +630,7 @@ class TestReconciliationEndpoints:
|
|||||||
r = await client.post("/reconciliation/state-change", json={
|
r = await client.post("/reconciliation/state-change", json={
|
||||||
"target_type": "task",
|
"target_type": "task",
|
||||||
"target_id": task["id"],
|
"target_id": task["id"],
|
||||||
"target_status": "in_progress",
|
"target_status": "progress",
|
||||||
"actor": "dashboard",
|
"actor": "dashboard",
|
||||||
"expected_current_status": "todo",
|
"expected_current_status": "todo",
|
||||||
"apply": True,
|
"apply": True,
|
||||||
@@ -641,13 +641,13 @@ class TestReconciliationEndpoints:
|
|||||||
assert body["write_through_result"] == "applied"
|
assert body["write_through_result"] == "applied"
|
||||||
text = wp.read_text(encoding="utf-8")
|
text = wp.read_text(encoding="utf-8")
|
||||||
assert "status: active" in text
|
assert "status: active" in text
|
||||||
assert "status: in_progress" in text
|
assert "status: progress" in text
|
||||||
|
|
||||||
r = await client.get(f"/workstreams/{ws['id']}")
|
r = await client.get(f"/workstreams/{ws['id']}")
|
||||||
assert r.json()["status"] == "active"
|
assert r.json()["status"] == "active"
|
||||||
|
|
||||||
r = await client.get(f"/tasks/{task['id']}")
|
r = await client.get(f"/tasks/{task['id']}")
|
||||||
assert r.json()["status"] == "in_progress"
|
assert r.json()["status"] == "progress"
|
||||||
|
|
||||||
async def test_apply_task_confirmation_case_creates_reconciliation_message(self, client, tmp_path):
|
async def test_apply_task_confirmation_case_creates_reconciliation_message(self, client, tmp_path):
|
||||||
await _create_domain(client)
|
await _create_domain(client)
|
||||||
@@ -682,7 +682,7 @@ class TestReconciliationEndpoints:
|
|||||||
r = await client.post("/reconciliation/state-change", json={
|
r = await client.post("/reconciliation/state-change", json={
|
||||||
"target_type": "task",
|
"target_type": "task",
|
||||||
"target_id": task["id"],
|
"target_id": task["id"],
|
||||||
"target_status": "blocked",
|
"target_status": "wait",
|
||||||
"apply": True,
|
"apply": True,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -698,7 +698,7 @@ class TestReconciliationEndpoints:
|
|||||||
r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
|
r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
|
||||||
messages = r.json()
|
messages = r.json()
|
||||||
assert len(messages) == 1
|
assert len(messages) == 1
|
||||||
assert "blocking reason" in messages[0]["body"]
|
assert "wait reason" in messages[0]["body"] or "wait condition" in messages[0]["body"]
|
||||||
|
|
||||||
async def test_apply_workstream_stale_expected_status_creates_conflict_message(self, client, tmp_path):
|
async def test_apply_workstream_stale_expected_status_creates_conflict_message(self, client, tmp_path):
|
||||||
await _create_domain(client)
|
await _create_domain(client)
|
||||||
@@ -792,7 +792,7 @@ class TestReconciliationEndpoints:
|
|||||||
r = await client.post("/reconciliation/state-change", json={
|
r = await client.post("/reconciliation/state-change", json={
|
||||||
"target_type": "task",
|
"target_type": "task",
|
||||||
"target_id": task["id"],
|
"target_id": task["id"],
|
||||||
"target_status": "in_progress",
|
"target_status": "progress",
|
||||||
"expected_current_status": "todo",
|
"expected_current_status": "todo",
|
||||||
"apply": True,
|
"apply": True,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
|
|||||||
id="tasks.all_done",
|
id="tasks.all_done",
|
||||||
target="tasks.*.status",
|
target="tasks.*.status",
|
||||||
op="all_eq",
|
op="all_eq",
|
||||||
value=["done", "cancelled"],
|
value=["done", "cancel"],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -26,7 +26,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = FlowEngine().evaluate(
|
result = FlowEngine().evaluate(
|
||||||
{"status": "active", "tasks": [{"status": "done"}, {"status": "cancelled"}]},
|
{"status": "active", "tasks": [{"status": "done"}, {"status": "cancel"}]},
|
||||||
flow,
|
flow,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ def test_failing_exit_assertion_identifies_blocking_assertion():
|
|||||||
id="tasks.all_done",
|
id="tasks.all_done",
|
||||||
target="tasks.*.status",
|
target="tasks.*.status",
|
||||||
op="all_eq",
|
op="all_eq",
|
||||||
value=["done", "cancelled"],
|
value=["done", "cancel"],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -87,7 +87,7 @@ def test_can_reach_checks_current_exit_assertions_before_target_entry():
|
|||||||
id="tasks.all_done",
|
id="tasks.all_done",
|
||||||
target="tasks.*.status",
|
target="tasks.*.status",
|
||||||
op="all_eq",
|
op="all_eq",
|
||||||
value=["done", "cancelled"],
|
value=["done", "cancel"],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -144,7 +144,7 @@ def test_empty_assertions_make_all_workstations_reachable():
|
|||||||
entity_type="task",
|
entity_type="task",
|
||||||
workstations=[
|
workstations=[
|
||||||
WorkstationDef(name="todo"),
|
WorkstationDef(name="todo"),
|
||||||
WorkstationDef(name="in_progress"),
|
WorkstationDef(name="progress"),
|
||||||
WorkstationDef(name="done"),
|
WorkstationDef(name="done"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -152,7 +152,7 @@ def test_empty_assertions_make_all_workstations_reachable():
|
|||||||
result = FlowEngine().evaluate({"status": "todo"}, flow)
|
result = FlowEngine().evaluate({"status": "todo"}, flow)
|
||||||
|
|
||||||
assert result.exit_blocked is False
|
assert result.exit_blocked is False
|
||||||
assert result.reachable == ["todo", "in_progress", "done"]
|
assert result.reachable == ["todo", "progress", "done"]
|
||||||
|
|
||||||
|
|
||||||
def test_circular_reference_in_target_path_does_not_loop_forever():
|
def test_circular_reference_in_target_path_does_not_loop_forever():
|
||||||
@@ -194,10 +194,10 @@ def test_yaml_flow_definitions_load_and_evaluate_representative_entities():
|
|||||||
assert "blocked" in [item.workstation for item in workstream_result.unreachable]
|
assert "blocked" in [item.workstation for item in workstream_result.unreachable]
|
||||||
|
|
||||||
task_result = FlowEngine().evaluate(
|
task_result = FlowEngine().evaluate(
|
||||||
{"status": "blocked", "needs_human": False},
|
{"status": "wait", "needs_human": False},
|
||||||
flows["task"],
|
flows["task"],
|
||||||
)
|
)
|
||||||
assert "in_progress" in task_result.reachable
|
assert "progress" in task_result.reachable
|
||||||
|
|
||||||
contribution_result = FlowEngine().evaluate(
|
contribution_result = FlowEngine().evaluate(
|
||||||
{"status": "acknowledged", "previous_workstation": "acknowledged"},
|
{"status": "acknowledged", "previous_workstation": "acknowledged"},
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ class TestTokenPassthrough:
|
|||||||
ws = await _create_workstream(client, topic["id"])
|
ws = await _create_workstream(client, topic["id"])
|
||||||
task = await _create_task(client, ws["id"])
|
task = await _create_task(client, ws["id"])
|
||||||
|
|
||||||
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
|
r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
|
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Task State Canon Adaptation"
|
title: "Task State Canon Adaptation"
|
||||||
domain: custodian
|
domain: custodian
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: proposed
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-05-25"
|
created: "2026-05-25"
|
||||||
@@ -78,9 +78,10 @@ changing code:
|
|||||||
Attached repos are the active repos registered in State Hub via `GET /repos/`.
|
Attached repos are the active repos registered in State Hub via `GET /repos/`.
|
||||||
They should not be edited directly from the State Hub repo session. Instead:
|
They should not be edited directly from the State Hub repo session. Instead:
|
||||||
|
|
||||||
1. Publish a State Hub interface-change record with `repo_slug=info-tech-canon`,
|
1. Publish a State Hub interface-change record with `repo_slug=state-hub`,
|
||||||
`interface_type=schema`, `change_type=breaking`, and the active registered
|
`interface_type=schema`, `change_type=breaking`, and the active registered
|
||||||
repo slugs in `affected_repo_slugs`.
|
repo slugs in `affected_repo_slugs`. The change cites `info-tech-canon` as
|
||||||
|
the canon source and State Hub as the schema/API publisher.
|
||||||
2. Let the interface-change publisher send inbox messages to affected repo
|
2. Let the interface-change publisher send inbox messages to affected repo
|
||||||
agents.
|
agents.
|
||||||
3. For repos that need local file or code changes, create ecosystem tasks with
|
3. For repos that need local file or code changes, create ecosystem tasks with
|
||||||
@@ -97,7 +98,7 @@ This uses the existing repo boundary rule and avoids cross-repo writes.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T01
|
id: STATE-WP-0052-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "09c4d7ef-d193-48ed-b127-611e26b70ba0"
|
state_hub_task_id: "09c4d7ef-d193-48ed-b127-611e26b70ba0"
|
||||||
```
|
```
|
||||||
@@ -120,7 +121,7 @@ Done when the design note can be used as the implementation source of truth.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T02
|
id: STATE-WP-0052-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c79ce637-7140-4a16-8d20-b89edee4b98f"
|
state_hub_task_id: "c79ce637-7140-4a16-8d20-b89edee4b98f"
|
||||||
```
|
```
|
||||||
@@ -146,7 +147,7 @@ required changes, and risk notes.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T03
|
id: STATE-WP-0052-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "206511ae-76a8-4c47-a1b2-114b115f8c1c"
|
state_hub_task_id: "206511ae-76a8-4c47-a1b2-114b115f8c1c"
|
||||||
```
|
```
|
||||||
@@ -170,7 +171,7 @@ task-status literal sets except in tests that intentionally assert them.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T04
|
id: STATE-WP-0052-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "bf4c6891-64a5-49e6-9bf6-d70577d494e7"
|
state_hub_task_id: "bf4c6891-64a5-49e6-9bf6-d70577d494e7"
|
||||||
```
|
```
|
||||||
@@ -196,7 +197,7 @@ task values.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T05
|
id: STATE-WP-0052-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c9e6a649-8e1c-4d3c-b51a-1814687a99be"
|
state_hub_task_id: "c9e6a649-8e1c-4d3c-b51a-1814687a99be"
|
||||||
```
|
```
|
||||||
@@ -219,7 +220,7 @@ still sync through a deliberate migration window.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T06
|
id: STATE-WP-0052-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "0aaea886-9686-4fa7-94e5-e5a6649489e8"
|
state_hub_task_id: "0aaea886-9686-4fa7-94e5-e5a6649489e8"
|
||||||
```
|
```
|
||||||
@@ -242,7 +243,7 @@ Done when dashboard, MCP, and docs no longer present the old values as canonical
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T07
|
id: STATE-WP-0052-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c884eea6-1b69-4636-86f2-475d5ec4ea9b"
|
state_hub_task_id: "c884eea6-1b69-4636-86f2-475d5ec4ea9b"
|
||||||
```
|
```
|
||||||
@@ -266,7 +267,7 @@ reintroduction of old canonical literals.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T08
|
id: STATE-WP-0052-T08
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b659724d-bda1-4dec-9af1-4d5e08f4db94"
|
state_hub_task_id: "b659724d-bda1-4dec-9af1-4d5e08f4db94"
|
||||||
```
|
```
|
||||||
@@ -292,7 +293,7 @@ Done when the brief is ready to embed in interface-change messages and
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T09
|
id: STATE-WP-0052-T09
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6479748b-c922-41e3-ad12-15015b2f56a9"
|
state_hub_task_id: "6479748b-c922-41e3-ad12-15015b2f56a9"
|
||||||
```
|
```
|
||||||
@@ -306,7 +307,7 @@ Requirements:
|
|||||||
State Hub clients, repos with workplans/agent instructions, repos with task
|
State Hub clients, repos with workplans/agent instructions, repos with task
|
||||||
status code, and no-direct-impact repos;
|
status code, and no-direct-impact repos;
|
||||||
- create and publish one `schema` / `breaking` interface-change record from
|
- create and publish one `schema` / `breaking` interface-change record from
|
||||||
`info-tech-canon`;
|
State Hub, citing `info-tech-canon` as the canon source;
|
||||||
- include all active registered repos that should receive the canon notice in
|
- include all active registered repos that should receive the canon notice in
|
||||||
`affected_repo_slugs`;
|
`affected_repo_slugs`;
|
||||||
- create `[repo:<slug>]` ecosystem tasks for repos that need local adaptation;
|
- create `[repo:<slug>]` ecosystem tasks for repos that need local adaptation;
|
||||||
@@ -320,7 +321,7 @@ task, or a recorded no-impact classification.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0052-T10
|
id: STATE-WP-0052-T10
|
||||||
status: todo
|
status: wait
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "1cde226a-6287-4db4-9d2f-7fa9ed0b6c4d"
|
state_hub_task_id: "1cde226a-6287-4db4-9d2f-7fa9ed0b6c4d"
|
||||||
```
|
```
|
||||||
@@ -341,6 +342,11 @@ Requirements:
|
|||||||
Done when State Hub is canon-conformant, attached repos have been notified, and
|
Done when State Hub is canon-conformant, attached repos have been notified, and
|
||||||
the remaining compatibility window is explicit.
|
the remaining compatibility window is explicit.
|
||||||
|
|
||||||
|
Current wait condition: attached repos have been notified through interface
|
||||||
|
change `649102a2-4373-4621-9848-cc257e67c262`; closing the compatibility window
|
||||||
|
depends on repo-agent responses and a later decision on when aliases become
|
||||||
|
warnings or errors.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- State Hub persists canonical task states as `wait`, `todo`, `progress`,
|
- State Hub persists canonical task states as `wait`, `todo`, `progress`,
|
||||||
|
|||||||
Reference in New Issue
Block a user