Close lifecycle transition helper workplan

This commit is contained in:
2026-05-23 18:42:32 +02:00
parent ab23ef4f1f
commit 0ea46f081c
8 changed files with 221 additions and 19 deletions

View File

@@ -16,9 +16,10 @@ from api.flow_defs import (
)
from api.models.capability_request import CapabilityRequest
from api.models.contribution import Contribution
from api.models.task import Task
from api.models.task import Task, TaskStatus
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.services.lifecycle import transition_task_status, transition_workstream_status
from api.workplan_status import normalize_workstream_status
router = APIRouter(prefix="/flows", tags=["flows"])
@@ -92,7 +93,18 @@ async def advance_workstation(
)
entity = await _entity(entity_type, entity_id, session)
entity.status = target_workstation
if entity_type == "workstream":
transition_workstream_status(entity, target_workstation)
elif entity_type == "task":
parent = await session.get(Workstream, entity.workstream_id)
transition_task_status(
entity,
target_workstation,
parent_workstream=parent,
status_coercer=TaskStatus,
)
else:
entity.status = target_workstation
await session.commit()
await session.refresh(entity)
return await get_flow_state(entity_type, entity_id, session)

View File

@@ -11,7 +11,12 @@ from api.models.task import Task
from api.models.task import TaskStatus
from api.models.workstream import Workstream
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
from api.services.lifecycle import status_value
from api.services.lifecycle import (
should_activate_parent_for_task_start,
status_value,
transition_task_status,
transition_workstream_status,
)
from api.services.reconciliation import (
ReconciliationClass,
StateChangeClassification,
@@ -172,7 +177,7 @@ async def classify_state_change(
)
conflict = True
else:
ws.status = target_status
transition_workstream_status(ws, target_status)
await session.commit()
write_result = "applied"
@@ -281,24 +286,54 @@ async def classify_state_change(
)
conflict = True
else:
original_text = None
parent_will_activate = should_activate_parent_for_task_start(
previous_task_status=current_status,
new_task_status=target_status,
parent_workstream_status=ws.status if ws else None,
)
try:
original_text = workplan_ref.path.read_text(encoding="utf-8")
patch_task_status(workplan_ref.path, task.id, target_status)
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
if parent_will_activate:
patch_workplan_status(workplan_ref.path, "active")
parent_status = normalize_workstream_status(workplan_status(workplan_ref.path))
if parent_status != "active":
if original_text is not None:
workplan_ref.path.write_text(original_text, encoding="utf-8")
classification = _conflict(
"parent workplan status could not be patched to 'active'",
"inspect the workplan frontmatter format before retrying",
)
conflict = True
except OSError as exc:
if original_text is not None:
workplan_ref.path.write_text(original_text, encoding="utf-8")
classification = _conflict(
f"workplan task write failed: {exc}",
"fix repo file access and retry the reconciliation",
)
conflict = True
else:
if patched_status != target_status:
if conflict:
pass
elif patched_status != target_status:
if original_text is not None:
workplan_ref.path.write_text(original_text, encoding="utf-8")
classification = _conflict(
f"workplan task block could not be patched to {target_status!r}",
"inspect the task block format before retrying",
)
conflict = True
else:
task.status = TaskStatus(target_status)
transition = transition_task_status(
task,
target_status,
parent_workstream=ws,
previous_task_status=current_status,
status_coercer=TaskStatus,
)
if body.blocking_reason is not None:
task.blocking_reason = body.blocking_reason
await session.commit()

View File

@@ -10,7 +10,7 @@ from api.models.task import Task, TaskStatus
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
from api.services.lifecycle import activate_parent_for_task_start, status_value
from api.services.lifecycle import status_value, transition_task_status
router = APIRouter(prefix="/tasks", tags=["tasks"])
@@ -52,10 +52,11 @@ async def create_task(
session.add(task)
if status_value(task.status) == "in_progress":
ws = await session.get(Workstream, task.workstream_id)
activate_parent_for_task_start(
previous_task_status="todo",
new_task_status=task.status,
transition_task_status(
task,
task.status,
parent_workstream=ws,
previous_task_status="todo",
)
await session.commit()
await session.refresh(task)
@@ -100,17 +101,18 @@ async def update_task(
update_data = body.model_dump(exclude_unset=True)
token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_field_names}
suppress_token_event = bool(token_data.pop("suppress_token_event", False))
status_update = update_data.get("status")
status_update = update_data.pop("status", None)
new_status = status_value(status_update) if status_update is not None else None
for field, value in update_data.items():
setattr(task, field, value)
if new_status is not None:
ws = await session.get(Workstream, task.workstream_id)
activate_parent_for_task_start(
previous_task_status=previous_status,
new_task_status=new_status,
transition_task_status(
task,
status_update,
parent_workstream=ws,
previous_task_status=previous_status,
)
await session.commit()
await session.refresh(task)
@@ -196,7 +198,7 @@ async def cancel_task(
task = await session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
task.status = TaskStatus.cancelled
transition_task_status(task, TaskStatus.cancelled)
await session.commit()
await session.refresh(task)
return task

View File

@@ -19,6 +19,7 @@ from api.schemas.workstream import (
WorkstreamRead,
WorkstreamUpdate,
)
from api.services.lifecycle import transition_workstream_status
from api.workplan_status import (
is_supported_workstream_status,
normalize_workstream_status,
@@ -184,9 +185,13 @@ async def update_workstream(
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
update_data = body.model_dump(exclude_unset=True)
status_update = update_data.pop("status", None)
prev_status = ws.status
for field, value in body.model_dump(exclude_unset=True).items():
for field, value in update_data.items():
setattr(ws, field, value)
if status_update is not None:
transition_workstream_status(ws, status_update)
await session.commit()
await session.refresh(ws)
@@ -216,7 +221,7 @@ async def archive_workstream(
ws = await session.get(Workstream, workstream_id)
if ws is None:
raise HTTPException(status_code=404, detail="Workstream not found")
ws.status = "archived"
transition_workstream_status(ws, "archived")
await session.commit()
await session.refresh(ws)
return ws

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from api.workplan_status import normalize_workstream_status
@@ -11,6 +12,15 @@ TASK_ACTIVE_STATUSES = {"in_progress", "blocked"}
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
@dataclass(frozen=True)
class LifecycleTransitionResult:
entity_type: str
previous_status: str
target_status: str
changed: bool
parent_activated: bool = False
def status_value(status: Any) -> str:
if hasattr(status, "value"):
status = status.value
@@ -67,3 +77,49 @@ def activate_parent_for_task_start(
return False
parent_workstream.status = "active"
return True
def transition_workstream_status(
workstream: Any,
target_status: Any,
) -> LifecycleTransitionResult:
"""Apply a canonical workstream status transition."""
previous_status = normalize_workstream_status(getattr(workstream, "status", None))
normalised_target = normalize_workstream_status(target_status)
workstream.status = normalised_target
return LifecycleTransitionResult(
entity_type="workstream",
previous_status=previous_status,
target_status=normalised_target,
changed=previous_status != normalised_target,
)
def transition_task_status(
task: Any,
target_status: Any,
*,
parent_workstream: Any = None,
previous_task_status: Any = None,
status_coercer: Any = None,
) -> LifecycleTransitionResult:
"""Apply a task status transition and activate the parent when work starts."""
previous_status = status_value(
getattr(task, "status", None)
if previous_task_status is None
else previous_task_status
)
normalised_target = status_value(target_status)
task.status = status_coercer(normalised_target) if status_coercer else target_status
parent_activated = activate_parent_for_task_start(
previous_task_status=previous_status,
new_task_status=normalised_target,
parent_workstream=parent_workstream,
)
return LifecycleTransitionResult(
entity_type="task",
previous_status=previous_status,
target_status=normalised_target,
changed=previous_status != normalised_target,
parent_activated=parent_activated,
)