feat(tasks): adopt canonical task statuses

This commit is contained in:
2026-05-26 01:32:50 +02:00
parent da5aee6e38
commit 38835e9e79
61 changed files with 692 additions and 342 deletions

View File

@@ -35,7 +35,7 @@ async def _create_workstream(client, topic_id):
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={
"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()
if status != "todo":
patch = {"status": status}
if status == "blocked":
if status == "wait":
patch["blocking_reason"] = "Waiting for capability request"
r2 = await client.patch(f"/tasks/{task['id']}", json=patch)
assert r2.status_code == 200, r2.text
@@ -229,7 +229,7 @@ class TestCapabilityRequestLifecycle:
await _setup_two_domains(client)
topic = await _create_topic(client, "custodian")
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"])

View File

@@ -558,11 +558,11 @@ class TestStatusOrder:
def test_todo_is_lowest(self):
assert STATUS_ORDER["todo"] == 0
def test_done_and_cancelled_are_highest(self):
assert STATUS_ORDER["done"] == STATUS_ORDER["cancelled"] == 2
def test_done_and_cancel_are_highest(self):
assert STATUS_ORDER["done"] == STATUS_ORDER["cancel"] == 2
def test_in_progress_and_blocked_are_mid(self):
assert STATUS_ORDER["in_progress"] == STATUS_ORDER["blocked"] == 1
def test_progress_and_wait_are_mid(self):
assert STATUS_ORDER["progress"] == STATUS_ORDER["wait"] == 1
def test_db_ahead_detected(self):
"""done (DB) vs todo (file) — DB is ahead."""
@@ -573,8 +573,8 @@ class TestStatusOrder:
assert STATUS_ORDER["todo"] < STATUS_ORDER["done"]
def test_same_rank_treated_as_db_ahead(self):
"""in_progress (DB) vs blocked (file) — same rank, no regression."""
assert STATUS_ORDER["in_progress"] >= STATUS_ORDER["blocked"]
"""progress (DB) vs wait (file) — same rank, no regression."""
assert STATUS_ORDER["progress"] >= STATUS_ORDER["wait"]
def test_todo_to_done_is_regression(self):
"""Applying file=todo to DB=done would be a regression."""
@@ -718,7 +718,7 @@ class TestPatchTaskStatusInFile:
content = (
"---\nid: WP-001\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)
_patch_task_status_in_file(f, "T02", "done")
@@ -783,7 +783,7 @@ class TestLifecycleRenormalization:
"## Implement Demo\n\n"
"```task\n"
"id: STATE-WP-0001-T01\n"
"status: in_progress\n"
"status: progress\n"
"priority: high\n"
"state_hub_task_id: \"task-1\"\n"
"```\n",
@@ -805,7 +805,7 @@ class TestLifecycleRenormalization:
task = {
"id": "task-1",
"title": "Implement Demo",
"status": "in_progress",
"status": "progress",
"description": None,
}

View File

@@ -16,7 +16,7 @@ from api.services.lifecycle import (
def test_task_start_activates_planning_parent(parent_status):
assert should_activate_parent_for_task_start(
previous_task_status="todo",
new_task_status="in_progress",
new_task_status="progress",
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):
assert not should_activate_parent_for_task_start(
previous_task_status="todo",
new_task_status="in_progress",
new_task_status="progress",
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(
previous_task_status="in_progress",
new_task_status="in_progress",
previous_task_status="progress",
new_task_status="progress",
parent_workstream_status="ready",
)
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():
assert has_active_task_status(["todo", "done", "cancelled"]) is False
assert has_active_task_status(["todo", "blocked"]) is True
assert has_active_task_status(["in_progress"]) is True
assert has_active_task_status(["todo", "done", "cancel"]) is False
assert has_active_task_status(["todo", "wait"]) is True
assert has_active_task_status(["progress"]) is True
def test_active_task_state_activates_planning_parent_for_renormalization():
assert should_activate_parent_for_active_tasks(
parent_workstream_status="proposed",
task_statuses=["todo", "in_progress"],
task_statuses=["todo", "progress"],
)
def test_active_task_state_does_not_unblock_blocked_parent():
assert not should_activate_parent_for_active_tasks(
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:
value = "In_Progress"
assert status_value(Status()) == "in_progress"
assert status_value(Status()) == "progress"
def test_transition_workstream_status_normalizes_aliases():
@@ -92,10 +92,10 @@ def test_transition_task_status_activates_parent_once():
task = Task()
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 result.parent_activated is True
assert result.previous_status == "todo"
assert result.target_status == "in_progress"
assert result.target_status == "progress"

View File

@@ -68,7 +68,7 @@ def test_archived_workstream_file_requires_confirmation():
def test_task_status_update_writes_through_to_task_block():
result = classify_task_status_change(
current_status="todo",
target_status="in_progress",
target_status="progress",
file_backed=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():
result = classify_task_status_change(
current_status="todo",
target_status="in_progress",
target_status="progress",
file_backed=False,
task_linked=True,
)
@@ -92,7 +92,7 @@ def test_task_without_file_is_deferred():
def test_unlinked_task_is_deferred_until_link_repaired():
result = classify_task_status_change(
current_status="todo",
target_status="in_progress",
target_status="progress",
file_backed=True,
task_linked=False,
)
@@ -101,22 +101,22 @@ def test_unlinked_task_is_deferred_until_link_repaired():
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(
current_status="todo",
target_status="blocked",
target_status="wait",
file_backed=True,
task_linked=True,
)
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(
current_status="todo",
target_status="blocked",
target_status="wait",
file_backed=True,
task_linked=True,
blocking_reason="Waiting on dependency",

View File

@@ -207,7 +207,7 @@ class TestTasks:
r = await client.delete(f"/tasks/{task['id']}")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
assert r.json()["status"] == "cancel"
async def test_filter_by_priority(self, client):
await _create_domain(client)
@@ -238,7 +238,7 @@ class TestTasks:
)
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
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")
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
r = await client.get(f"/workstreams/{ws['id']}")
@@ -326,13 +326,13 @@ class TestStateSummary:
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["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']}",
json={"status": "blocked", "blocking_reason": "waiting on dep"})
json={"status": "wait", "blocking_reason": "waiting on dep"})
r = await client.get("/state/summary")
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):
await _create_domain(client)
@@ -449,7 +449,7 @@ class TestReconciliationEndpoints:
assert body["reconciliation_class"] == "human_confirmation"
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)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
@@ -458,7 +458,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "blocked",
"target_status": "wait",
"file_backed": True,
"task_linked": True,
})
@@ -467,7 +467,7 @@ class TestReconciliationEndpoints:
body = r.json()
assert body["current_status"] == "todo"
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):
r = await client.post("/reconciliation/state-change", json={
@@ -582,7 +582,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"target_status": "progress",
"actor": "dashboard",
"apply": True,
})
@@ -592,10 +592,10 @@ class TestReconciliationEndpoints:
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied"
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']}")
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):
await _create_domain(client)
@@ -630,7 +630,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"target_status": "progress",
"actor": "dashboard",
"expected_current_status": "todo",
"apply": True,
@@ -641,13 +641,13 @@ class TestReconciliationEndpoints:
assert body["write_through_result"] == "applied"
text = wp.read_text(encoding="utf-8")
assert "status: active" in text
assert "status: in_progress" in text
assert "status: progress" in text
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
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):
await _create_domain(client)
@@ -682,7 +682,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "blocked",
"target_status": "wait",
"apply": True,
})
@@ -698,7 +698,7 @@ class TestReconciliationEndpoints:
r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
messages = r.json()
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):
await _create_domain(client)
@@ -792,7 +792,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"target_status": "progress",
"expected_current_status": "todo",
"apply": True,
})

View File

@@ -18,7 +18,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
id="tasks.all_done",
target="tasks.*.status",
op="all_eq",
value=["done", "cancelled"],
value=["done", "cancel"],
)
],
),
@@ -26,7 +26,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
)
result = FlowEngine().evaluate(
{"status": "active", "tasks": [{"status": "done"}, {"status": "cancelled"}]},
{"status": "active", "tasks": [{"status": "done"}, {"status": "cancel"}]},
flow,
)
@@ -47,7 +47,7 @@ def test_failing_exit_assertion_identifies_blocking_assertion():
id="tasks.all_done",
target="tasks.*.status",
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",
target="tasks.*.status",
op="all_eq",
value=["done", "cancelled"],
value=["done", "cancel"],
)
],
),
@@ -144,7 +144,7 @@ def test_empty_assertions_make_all_workstations_reachable():
entity_type="task",
workstations=[
WorkstationDef(name="todo"),
WorkstationDef(name="in_progress"),
WorkstationDef(name="progress"),
WorkstationDef(name="done"),
],
)
@@ -152,7 +152,7 @@ def test_empty_assertions_make_all_workstations_reachable():
result = FlowEngine().evaluate({"status": "todo"}, flow)
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():
@@ -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]
task_result = FlowEngine().evaluate(
{"status": "blocked", "needs_human": False},
{"status": "wait", "needs_human": False},
flows["task"],
)
assert "in_progress" in task_result.reachable
assert "progress" in task_result.reachable
contribution_result = FlowEngine().evaluate(
{"status": "acknowledged", "previous_workstation": "acknowledged"},

View File

@@ -173,7 +173,7 @@ class TestTokenPassthrough:
ws = await _create_workstream(client, topic["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
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()