feat: add State Hub bulk status skill

This commit is contained in:
2026-06-07 20:11:07 +02:00
parent 8f17bc1f50
commit 55e36bdf2d
9 changed files with 496 additions and 5 deletions

View File

@@ -6,10 +6,18 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.progress_event import ProgressEvent
from api.models.task import Task, TaskStatus
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.schemas.task import TaskCountRead, TaskCreate, TaskRead, TaskUpdate
from api.schemas.task import (
TaskCountRead,
TaskCreate,
TaskRead,
TaskStatusBulkSync,
TaskStatusBulkSyncRead,
TaskUpdate,
)
from api.services.lifecycle import status_value, transition_task_status
from api.task_status import normalize_task_status
@@ -88,6 +96,84 @@ async def create_task(
return task
@router.post("/bulk-status-sync", response_model=TaskStatusBulkSyncRead)
async def bulk_status_sync(
body: TaskStatusBulkSync,
session: AsyncSession = Depends(get_session),
) -> TaskStatusBulkSyncRead:
seen: set[uuid.UUID] = set()
duplicate_ids: list[str] = []
tasks_by_id: dict[uuid.UUID, Task] = {}
missing_ids: list[str] = []
for update in body.updates:
if update.task_id in seen:
duplicate_ids.append(str(update.task_id))
continue
seen.add(update.task_id)
task = await session.get(Task, update.task_id)
if task is None:
missing_ids.append(str(update.task_id))
else:
tasks_by_id[update.task_id] = task
if duplicate_ids:
raise HTTPException(
status_code=400,
detail={"message": "duplicate task_id values are not allowed", "task_ids": duplicate_ids},
)
if missing_ids:
raise HTTPException(
status_code=404,
detail={"message": "one or more tasks were not found", "task_ids": missing_ids},
)
updated: list[Task] = []
events: list[ProgressEvent] = []
author = body.author or "custodian"
for update in body.updates:
task = tasks_by_id[update.task_id]
previous_status = status_value(task.status)
target_status = status_value(update.status)
if update.blocking_reason is not None:
task.blocking_reason = update.blocking_reason
ws = await session.get(Workstream, task.workstream_id)
transition_task_status(
task,
update.status,
parent_workstream=ws,
previous_task_status=previous_status,
)
event = ProgressEvent(
task_id=task.id,
workstream_id=task.workstream_id,
event_type="task_status_changed",
summary=f"Task status -> {target_status}: {task.title}",
author=author,
session_id=body.session_id,
detail={
"bulk_status_sync": True,
"previous_status": previous_status,
"status": target_status,
"blocking_reason": update.blocking_reason,
},
)
session.add(event)
updated.append(task)
events.append(event)
await session.commit()
for task in updated:
await session.refresh(task)
for event in events:
await session.refresh(event)
return TaskStatusBulkSyncRead(
updated=updated,
progress_event_ids=[event.id for event in events],
)
@router.get("/{task_id}", response_model=TaskRead)
async def get_task(
task_id: uuid.UUID,

View File

@@ -77,6 +77,25 @@ class TaskUpdate(TaskStatusMixin):
return self
class TaskStatusBulkUpdate(TaskStatusMixin):
task_id: uuid.UUID
status: TaskStatus
blocking_reason: str | None = None
class TaskStatusBulkSync(BaseModel):
updates: list[TaskStatusBulkUpdate]
author: str | None = "custodian"
session_id: str | None = None
@field_validator("updates")
@classmethod
def updates_required(cls, value: list[TaskStatusBulkUpdate]):
if not value:
raise ValueError("at least one task status update is required")
return value
class TaskRead(TaskStatusMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
@@ -99,3 +118,8 @@ class TaskCountRead(TaskStatusMixin):
workstream_id: uuid.UUID
status: TaskStatus
count: int
class TaskStatusBulkSyncRead(BaseModel):
updated: list[TaskRead]
progress_event_ids: list[uuid.UUID]

View File

@@ -31,6 +31,7 @@ endpoint they wrap:
| `create_workstream(...)` | `POST /workstreams/` |
| `create_task(...)` | `POST /tasks/` |
| `update_task_status(...)` | `PATCH /tasks/{task_id}` |
| `bulk_update_task_statuses(...)` | `POST /tasks/bulk-status-sync` |
| `record_decision(...)` | `POST /decisions/` |
| `add_progress_event(...)` | `POST /progress/` |
@@ -93,6 +94,7 @@ entity while recording the missing progress event.
| `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. |
| `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. |
| `bulk_update_task_statuses(updates, author?, session_id?)` | `updates`: list of `{task_id, status, blocking_reason?}` | Updates many task statuses in one REST call and emits one `task_status_changed` progress event per task. Prefer this at session checkpoints instead of many single-task calls. |
| `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. |

View File

@@ -633,6 +633,34 @@ def update_task_status(
return _json_result(task)
@mcp.tool()
def bulk_update_task_statuses(
updates: list[dict[str, Any]],
author: str | None = "custodian",
session_id: str | None = None,
) -> str:
"""Update many task statuses in one call and emit one progress_event per task.
Args:
updates: list of {task_id, status, blocking_reason?}; status values are
wait | todo | progress | done | cancel
author: optional progress event author (defaults to custodian)
session_id: optional agent session identifier for progress events
"""
result = _post("/tasks/bulk-status-sync", {
"updates": updates,
"author": author,
"session_id": session_id,
})
if error := _response_error(
"bulk_update_task_statuses",
result,
("updated", "progress_event_ids"),
):
return _json_result(error)
return _json_result(result)
@mcp.tool()
def flag_for_human(task_id: str, note: str) -> str:
"""Flag a task as requiring human intervention.

65
skills/state-hub/SKILL.md Normal file
View File

@@ -0,0 +1,65 @@
---
name: state-hub
description: Use when coordinating with Custodian State Hub: orienting with domain summaries, checking agent inbox messages, updating workplan-backed task status, recording decisions/progress, or batching task status sync through MCP/REST without re-discovering tool schemas.
---
# State Hub Coordination
Use this skill at the start and close of State Hub aware coding sessions. The
hub is a read/cache/index model over repo-owned workplan files; do not invent
work structure in the hub when a workplan file is the canon.
## Session Flow
1. Orient with `get_domain_summary(domain_slug)` when working inside one domain
repo. Use `get_state_summary()` only for cross-domain/custodian-wide work.
2. Check inbox with `get_messages(to_agent=<repo-slug>, unread_only=true)`.
Mark acted-on messages with `mark_message_read(message_id)`.
3. During work, edit the workplan file first. Mirror task/workstream status to
the hub at checkpoints.
4. Prefer `bulk_update_task_statuses(...)` for checkpoint syncs with multiple
task updates. Use `update_task_status(...)` for one-off changes.
5. Close with one concise `add_progress_event(...)`, then run the repo's
`make fix-consistency REPO=<repo-slug>` command when workplan files changed.
## High-Frequency MCP Signatures
```text
get_domain_summary(domain_slug: str) -> str
get_messages(to_agent?: str, from_agent?: str, unread_only: bool = false, limit: int = 20) -> str
send_message(from_agent: str, to_agent: str, subject: str, body: str, thread_id?: str) -> str
create_workstream(topic_id: str, title: str, slug?: str, description?: str, owner?: str, due_date?: str, repo_id?: str, planning_priority?: str, planning_order?: int) -> str
create_task(workstream_id: str, title: str, priority: str = "medium", description?: str, assignee?: str, due_date?: str) -> str
update_task_status(task_id: str, status: str, blocking_reason?: str, tokens_in?: int, tokens_out?: int, workplan_tokens_in?: int, workplan_tokens_out?: int, note?: str, model?: str, agent?: str, session_id?: str) -> str
bulk_update_task_statuses(updates: list[dict], author?: str = "custodian", session_id?: str) -> str
add_progress_event(summary: str, event_type: str = "note", topic_id?: str, workstream_id?: str, task_id?: str, detail?: dict | str) -> str
record_decision(title: str, decision_type: str = "pending", topic_id?: str, workstream_id?: str, description?: str, rationale?: str, decided_by?: str, deadline?: str) -> str
```
`bulk_update_task_statuses` updates `N` task statuses in one call:
```json
{
"updates": [
{"task_id": "<uuid>", "status": "progress"},
{"task_id": "<uuid>", "status": "wait", "blocking_reason": "waiting for operator"}
],
"author": "codex",
"session_id": "<optional-session-id>"
}
```
Each bulk item emits a `task_status_changed` progress event. Keep separate
progress notes coarse: milestones, blockers, handoffs, or final summaries.
## Boundaries
- Canon lives in files: workplans, `INTENT.md`, repo docs, and commits.
- The hub indexes and broadcasts state; it is not a substitute workplan author.
- For new multi-step work, create or update the workplan file, then sync.
- If MCP returns an error payload, use the matching REST endpoint as fallback
and record what happened once the write succeeds.
For REST paths, response shapes, and fallback examples, read
`references/tool-signatures.md`.

View File

@@ -0,0 +1,97 @@
# State Hub Tool Signatures
Load this reference when a session needs exact REST fallback paths or batched
write payloads.
## Orientation
```text
MCP: get_domain_summary(domain_slug)
REST: GET /state/summary then filter by topic/domain when MCP is unavailable
```
Use `get_domain_summary("custodian")` inside State Hub work. It returns the
domain topic, active workstreams, blocking decisions, recent progress, repos,
and compact capability hints.
## Agent Messages
```text
MCP: get_messages(to_agent?, from_agent?, unread_only?, limit?)
REST: GET /messages/?to_agent=<repo>&unread_only=true
MCP: send_message(from_agent, to_agent, subject, body, thread_id?)
REST: POST /messages/
MCP: mark_message_read(message_id)
REST: PATCH /messages/{message_id}/read
```
Use repo slugs as agent names. Use `broadcast` only for genuinely shared
coordination.
## Workstreams and Tasks
```text
MCP: create_workstream(topic_id, title, slug?, description?, owner?, due_date?, repo_id?, planning_priority?, planning_order?)
REST: POST /workstreams/
MCP: create_task(workstream_id, title, priority="medium", description?, assignee?, due_date?)
REST: POST /tasks/
MCP: update_task_status(task_id, status, blocking_reason?, tokens_in?, tokens_out?, workplan_tokens_in?, workplan_tokens_out?, note?, model?, agent?, session_id?)
REST: PATCH /tasks/{task_id}
```
Canonical task statuses are `wait`, `todo`, `progress`, `done`, and `cancel`.
Legacy aliases are accepted during migration, but do not emit new workplan files
with old vocabulary.
## Bulk Task Status Sync
```text
MCP: bulk_update_task_statuses(updates, author?, session_id?)
REST: POST /tasks/bulk-status-sync
```
Payload:
```json
{
"updates": [
{"task_id": "uuid-1", "status": "progress"},
{"task_id": "uuid-2", "status": "done"},
{"task_id": "uuid-3", "status": "wait", "blocking_reason": "needs approval"}
],
"author": "codex",
"session_id": "optional-session-id"
}
```
Response:
```json
{
"updated": [
{"id": "uuid-1", "status": "progress", "...": "..."}
],
"progress_event_ids": ["event-uuid-1"]
}
```
The endpoint rejects duplicate task ids with `400` and missing task ids with
`404` before changing any task. Each successful item emits one
`task_status_changed` progress event with `detail.bulk_status_sync = true`.
## Progress and Decisions
```text
MCP: add_progress_event(summary, event_type="note", topic_id?, workstream_id?, task_id?, detail?)
REST: POST /progress/
MCP: record_decision(title, decision_type="pending", topic_id?, workstream_id?, description?, rationale?, decided_by?, deadline?)
REST: POST /decisions/
```
Prefer one progress event per checkpoint. A useful close event says what changed,
which tests ran, whether consistency sync passed, and what remains.

View File

@@ -180,6 +180,50 @@ class TestMCPWriteTools:
)
]
async def test_bulk_update_task_statuses_returns_rest_shape(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body))
assert path == "/tasks/bulk-status-sync"
return {
"updated": [
{"id": "task-1", "title": "First", "status": "done"},
{"id": "task-2", "title": "Second", "status": "wait"},
],
"progress_event_ids": ["event-1", "event-2"],
}
monkeypatch.setattr(server, "_post", fake_post)
body = await _call_tool(
"bulk_update_task_statuses",
{
"author": "codex",
"session_id": "session-1",
"updates": [
{"task_id": "task-1", "status": "done"},
{"task_id": "task-2", "status": "wait", "blocking_reason": "needs input"},
],
},
)
assert body["progress_event_ids"] == ["event-1", "event-2"]
assert [task["status"] for task in body["updated"]] == ["done", "wait"]
assert calls == [
(
"/tasks/bulk-status-sync",
{
"updates": [
{"task_id": "task-1", "status": "done"},
{"task_id": "task-2", "status": "wait", "blocking_reason": "needs input"},
],
"author": "codex",
"session_id": "session-1",
},
)
]
async def test_record_decision_returns_rest_shape_and_emits_progress(self, monkeypatch):
calls: list[tuple[str, dict[str, Any]]] = []

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
async def _create_domain(client, slug: str = "bulk-domain"):
r = await client.post("/domains/", json={"slug": slug, "name": "Bulk Domain"})
assert r.status_code == 201
return r.json()
async def _create_topic(client, domain_slug: str = "bulk-domain"):
r = await client.post(
"/topics/",
json={"slug": "bulk-topic", "title": "Bulk Topic", "domain": domain_slug},
)
assert r.status_code == 201
return r.json()
async def _create_workstream(client, topic_id: str):
r = await client.post(
"/workstreams/",
json={"topic_id": topic_id, "slug": "bulk-ws", "title": "Bulk Workstream"},
)
assert r.status_code == 201
return r.json()
async def _create_task(client, workstream_id: str, title: str):
r = await client.post(
"/tasks/",
json={"workstream_id": workstream_id, "title": title},
)
assert r.status_code == 201
return r.json()
async def _seed_two_tasks(client):
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
first = await _create_task(client, ws["id"], "First bulk task")
second = await _create_task(client, ws["id"], "Second bulk task")
return ws, first, second
class TestTaskBulkStatusSync:
async def test_updates_many_tasks_and_emits_progress_events(self, client):
ws, first, second = await _seed_two_tasks(client)
r = await client.post(
"/tasks/bulk-status-sync",
json={
"author": "codex",
"session_id": "session-1",
"updates": [
{"task_id": first["id"], "status": "progress"},
{"task_id": second["id"], "status": "wait", "blocking_reason": "needs operator"},
],
},
)
assert r.status_code == 200
body = r.json()
assert [task["id"] for task in body["updated"]] == [first["id"], second["id"]]
assert [task["status"] for task in body["updated"]] == ["progress", "wait"]
assert body["updated"][1]["blocking_reason"] == "needs operator"
assert len(body["progress_event_ids"]) == 2
progress = await client.get("/progress/", params={"workstream_id": ws["id"]})
assert progress.status_code == 200
events = progress.json()
assert [event["id"] for event in events] == body["progress_event_ids"]
assert [event["event_type"] for event in events] == ["task_status_changed", "task_status_changed"]
assert events[0]["author"] == "codex"
assert events[0]["session_id"] == "session-1"
assert events[0]["detail"]["bulk_status_sync"] is True
assert events[0]["detail"]["previous_status"] == "todo"
assert events[0]["detail"]["status"] == "progress"
async def test_duplicate_task_ids_are_rejected_without_updates(self, client):
_, first, _ = await _seed_two_tasks(client)
r = await client.post(
"/tasks/bulk-status-sync",
json={
"updates": [
{"task_id": first["id"], "status": "progress"},
{"task_id": first["id"], "status": "done"},
],
},
)
assert r.status_code == 400
assert r.json()["detail"]["task_ids"] == [first["id"]]
task = await client.get(f"/tasks/{first['id']}")
assert task.status_code == 200
assert task.json()["status"] == "todo"
async def test_missing_task_ids_are_rejected_without_updates(self, client):
import uuid
_, first, _ = await _seed_two_tasks(client)
missing_id = str(uuid.uuid4())
r = await client.post(
"/tasks/bulk-status-sync",
json={
"updates": [
{"task_id": first["id"], "status": "progress"},
{"task_id": missing_id, "status": "done"},
],
},
)
assert r.status_code == 404
assert r.json()["detail"]["task_ids"] == [missing_id]
task = await client.get(f"/tasks/{first['id']}")
assert task.status_code == 200
assert task.json()["status"] == "todo"

View File

@@ -4,7 +4,7 @@ type: workplan
title: "State Hub Agent Skill — front-load tool schemas + batched writes"
domain: custodian
repo: state-hub
status: ready
status: finished
owner: codex
topic_slug: custodian
created: "2026-06-07"
@@ -37,7 +37,7 @@ of sessions).
```task
id: STATE-WP-0058-T01
status: todo
status: done
priority: high
state_hub_task_id: "6f211ccc-e505-419d-90cc-25874ba98f39"
```
@@ -54,7 +54,7 @@ model). Directly targets the `ToolSearch`-thrash finding.
```task
id: STATE-WP-0058-T02
status: todo
status: done
priority: high
state_hub_task_id: "2138505a-ea49-4ccc-9dec-a176badaa7a5"
```
@@ -69,7 +69,7 @@ preserve the automatic `progress_event` semantics on each write.
```task
id: STATE-WP-0058-T03
status: todo
status: done
priority: medium
state_hub_task_id: "cf8d41f8-7831-4c1b-9dfa-58660174294b"
```
@@ -83,3 +83,27 @@ workplan updates, notify the operator to run from `~/state-hub`:
```bash
make fix-consistency REPO=state-hub
```
## Verification Notes
Completed 2026-06-07:
- Added `skills/state-hub/SKILL.md` plus
`skills/state-hub/references/tool-signatures.md` to front-load the
high-frequency State Hub MCP signatures, session flow, ADR-001 files-first
boundary, REST fallbacks, and batched-write guidance.
- Added `POST /tasks/bulk-status-sync` for checkpoint task-status batching.
The endpoint updates all requested task statuses in one transaction, rejects
duplicate task ids with `400`, rejects missing task ids with `404`, and emits
one `task_status_changed` progress event per successful item.
- Added MCP wrapper `bulk_update_task_statuses(updates, author?, session_id?)`
and documented it in `mcp_server/TOOLS.md`.
- Sent completion/signature handoff to `helix_forge` as message
`7236bd1c-b60d-481a-bc05-3080c4b46f72`, followed by tracked-path
correction `1a322eed-96d6-45d4-9e0a-685a6f66e180`.
Verification:
- `.venv/bin/python -m pytest tests/test_task_bulk_status_sync.py tests/test_mcp_write_tools.py -q` -> 12 passed
- `.venv/bin/python -m pytest tests/test_task_bulk_status_sync.py tests/test_mcp_write_tools.py tests/test_mcp_smoke.py -q` -> 25 passed
- `git diff --check` -> clean