|
|
@@ -15,7 +15,6 @@ from agent.trace.goal_models import GoalTree
|
|
|
|
|
|
from .config import OrchestrationConfig
|
|
|
from .models import (
|
|
|
- AcceptanceCriterion,
|
|
|
AgentRole,
|
|
|
ArtifactRef,
|
|
|
AttemptStatus,
|
|
|
@@ -84,6 +83,12 @@ TERMINAL_TASK_STATUSES = {
|
|
|
TaskStatus.SUPERSEDED,
|
|
|
}
|
|
|
|
|
|
+ACTIVE_TASK_STATUSES = {
|
|
|
+ TaskStatus.RUNNING,
|
|
|
+ TaskStatus.AWAITING_VALIDATION,
|
|
|
+ TaskStatus.VALIDATING,
|
|
|
+}
|
|
|
+
|
|
|
|
|
|
class TaskCoordinator:
|
|
|
"""Coordinates Task -> Attempt -> Validation -> Planner Decision.
|
|
|
@@ -141,28 +146,135 @@ class TaskCoordinator:
|
|
|
def _idem(root_trace_id: str, key: Optional[str]) -> Optional[str]:
|
|
|
return f"{root_trace_id}:{key}" if key else None
|
|
|
|
|
|
- async def ensure_ledger(self, root_trace_id: str, mission: str) -> TaskLedger:
|
|
|
+ async def ensure_ledger(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ root_task_spec: Optional[Dict[str, Any]] = None,
|
|
|
+ *,
|
|
|
+ allow_create: bool = True,
|
|
|
+ ) -> TaskLedger:
|
|
|
async with self._lock(root_trace_id):
|
|
|
for attempt_number in range(8):
|
|
|
try:
|
|
|
- return await self.task_store.load(root_trace_id)
|
|
|
+ ledger = await self.task_store.load(root_trace_id)
|
|
|
+ if ledger.root_trace_id != root_trace_id:
|
|
|
+ raise OrchestrationError("Task ledger root trace identity is invalid")
|
|
|
+ self._root_task(ledger)
|
|
|
+ return ledger
|
|
|
except TaskStoreNotFound:
|
|
|
- ledger = TaskLedger(root_trace_id=root_trace_id, mission=mission)
|
|
|
+ if not allow_create:
|
|
|
+ raise TaskConflict(
|
|
|
+ "Existing explicit Planner Trace has no Root Task ledger; "
|
|
|
+ "rebuild the development trace"
|
|
|
+ )
|
|
|
+ if root_task_spec is None:
|
|
|
+ raise ValueError(
|
|
|
+ "root_task_spec is required for a new explicit-validation Planner trace"
|
|
|
+ )
|
|
|
+ if not isinstance(root_task_spec, dict):
|
|
|
+ raise ValueError("root_task_spec must be an object")
|
|
|
+ unknown = set(root_task_spec) - {
|
|
|
+ "objective", "acceptance_criteria", "context_refs",
|
|
|
+ }
|
|
|
+ if unknown:
|
|
|
+ raise ValueError(
|
|
|
+ f"root_task_spec contains unsupported fields: {sorted(unknown)}"
|
|
|
+ )
|
|
|
+ spec = self._task_spec_from_draft(root_task_spec, version=1)
|
|
|
+ root_task_id = new_id()
|
|
|
+ root = TaskRecord(
|
|
|
+ task_id=root_task_id,
|
|
|
+ goal_id=None,
|
|
|
+ parent_task_id=None,
|
|
|
+ display_path="0",
|
|
|
+ specs=[spec],
|
|
|
+ status=TaskStatus.NEEDS_REPLAN,
|
|
|
+ )
|
|
|
+ ledger = TaskLedger(
|
|
|
+ root_trace_id=root_trace_id,
|
|
|
+ mission=spec.objective,
|
|
|
+ root_task_id=root_task_id,
|
|
|
+ tasks={root_task_id: root},
|
|
|
+ focused_task_id=root_task_id,
|
|
|
+ )
|
|
|
try:
|
|
|
committed = await self.task_store.commit(
|
|
|
ledger,
|
|
|
expected_revision=-1,
|
|
|
- event=EventDraft("ledger_created", {"mission": mission}),
|
|
|
+ event=EventDraft(
|
|
|
+ "ledger_created",
|
|
|
+ {
|
|
|
+ "mission": spec.objective,
|
|
|
+ "root_task_id": root_task_id,
|
|
|
+ },
|
|
|
+ ),
|
|
|
)
|
|
|
except RevisionConflict:
|
|
|
if attempt_number == 7:
|
|
|
raise
|
|
|
await asyncio.sleep(_cas_backoff(attempt_number))
|
|
|
continue
|
|
|
- await self._emit(root_trace_id, "ledger_created", {"mission": mission})
|
|
|
+ await self._emit(
|
|
|
+ root_trace_id,
|
|
|
+ "ledger_created",
|
|
|
+ {"mission": spec.objective, "root_task_id": root_task_id},
|
|
|
+ )
|
|
|
return committed.ledger
|
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
+ async def mission_completion(self, root_trace_id: str) -> Dict[str, Any]:
|
|
|
+ """Return the derived mission state without persisting a second state machine."""
|
|
|
+
|
|
|
+ ledger = await self.task_store.load(root_trace_id)
|
|
|
+ root = self._root_task(ledger)
|
|
|
+ nonterminal_descendants = self._nonterminal_descendants(ledger, root)
|
|
|
+ if root.status == TaskStatus.COMPLETED and nonterminal_descendants:
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Completed root task has non-terminal descendants"
|
|
|
+ )
|
|
|
+ result_summary = None
|
|
|
+ if root.status == TaskStatus.COMPLETED:
|
|
|
+ _, attempt, _ = self._accepted_result_binding(ledger, root)
|
|
|
+ result_summary = attempt.submission.summary
|
|
|
+ pending = [
|
|
|
+ {
|
|
|
+ "task_id": task.task_id,
|
|
|
+ "display_path": task.display_path,
|
|
|
+ "status": task.status.value,
|
|
|
+ "objective": task.current_spec.objective,
|
|
|
+ }
|
|
|
+ for task in sorted(ledger.tasks.values(), key=lambda item: _path_key(item.display_path))
|
|
|
+ if task.status not in TERMINAL_TASK_STATUSES
|
|
|
+ ]
|
|
|
+ return {
|
|
|
+ "root_task_id": root.task_id,
|
|
|
+ "status": root.status.value,
|
|
|
+ "blocked_reason": root.blocked_reason,
|
|
|
+ "result_summary": result_summary,
|
|
|
+ "pending_tasks": pending,
|
|
|
+ }
|
|
|
+
|
|
|
+ async def inspect_tasks(self, root_trace_id: str) -> Dict[str, Any]:
|
|
|
+ ledger = await self.task_store.load(root_trace_id)
|
|
|
+ self._root_task(ledger)
|
|
|
+ return {
|
|
|
+ "root_task_id": ledger.root_task_id,
|
|
|
+ "focused_task_id": ledger.focused_task_id,
|
|
|
+ "tasks": [
|
|
|
+ {
|
|
|
+ "task_id": task.task_id,
|
|
|
+ "parent_task_id": task.parent_task_id,
|
|
|
+ "display_path": task.display_path,
|
|
|
+ "status": task.status.value,
|
|
|
+ "blocked_reason": task.blocked_reason,
|
|
|
+ "current_spec": _plain(asdict(task.current_spec)),
|
|
|
+ }
|
|
|
+ for task in sorted(
|
|
|
+ ledger.tasks.values(), key=lambda item: _path_key(item.display_path)
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
async def _mutate(
|
|
|
self,
|
|
|
root_trace_id: str,
|
|
|
@@ -262,13 +374,27 @@ class TaskCoordinator:
|
|
|
after_task_id = placement.get("after_task_id")
|
|
|
if after_task_id:
|
|
|
target = _task(ledger, after_task_id)
|
|
|
+ if target.task_id == ledger.root_task_id:
|
|
|
+ raise ValueError("The root task cannot have siblings")
|
|
|
if parent_task_id is not None and target.parent_task_id != parent_task_id:
|
|
|
raise ValueError("after_task_id must be a sibling under parent_task_id")
|
|
|
effective_parent_id = target.parent_task_id
|
|
|
else:
|
|
|
- effective_parent_id = parent_task_id
|
|
|
+ effective_parent_id = parent_task_id or ledger.root_task_id
|
|
|
if effective_parent_id and effective_parent_id not in ledger.tasks:
|
|
|
raise ValueError(f"Parent task not found: {effective_parent_id}")
|
|
|
+ if effective_parent_id is None:
|
|
|
+ raise OrchestrationError("Task ledger has no root task")
|
|
|
+ parent = ledger.tasks[effective_parent_id]
|
|
|
+ if parent.status not in {
|
|
|
+ TaskStatus.PENDING,
|
|
|
+ TaskStatus.NEEDS_REPLAN,
|
|
|
+ TaskStatus.WAITING_CHILDREN,
|
|
|
+ }:
|
|
|
+ raise TaskConflict(
|
|
|
+ f"Task {effective_parent_id} cannot receive children from "
|
|
|
+ f"{parent.status.value}"
|
|
|
+ )
|
|
|
siblings = sorted(
|
|
|
(t for t in ledger.tasks.values() if t.parent_task_id == effective_parent_id),
|
|
|
key=lambda item: _path_key(item.display_path),
|
|
|
@@ -277,7 +403,11 @@ class TaskCoordinator:
|
|
|
target_index = next(i for i, sibling in enumerate(siblings) if sibling.task_id == after_task_id)
|
|
|
start = target_index + 2
|
|
|
for index, sibling in enumerate(siblings[target_index + 1:], start=start + len(drafts)):
|
|
|
- sibling.display_path = self._display_path(ledger, effective_parent_id, index)
|
|
|
+ self._rebase_task_path(
|
|
|
+ ledger,
|
|
|
+ sibling,
|
|
|
+ self._display_path(ledger, effective_parent_id, index),
|
|
|
+ )
|
|
|
else:
|
|
|
start = len(siblings) + 1
|
|
|
parent_path = ledger.tasks[effective_parent_id].display_path if effective_parent_id else ""
|
|
|
@@ -299,6 +429,7 @@ class TaskCoordinator:
|
|
|
else:
|
|
|
children.append(task_id)
|
|
|
created.append(task_id)
|
|
|
+ self._mark_parent_waiting(parent)
|
|
|
if placement and placement.get("focus") and created:
|
|
|
ledger.focused_task_id = created[0]
|
|
|
return {
|
|
|
@@ -894,6 +1025,10 @@ class TaskCoordinator:
|
|
|
task = _task(ledger, task_id)
|
|
|
if task.status not in (TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN):
|
|
|
raise TaskConflict(f"Task {task_id} cannot dispatch from {task.status.value}")
|
|
|
+ if self._nonterminal_descendants(ledger, task):
|
|
|
+ raise TaskConflict(
|
|
|
+ f"Task {task_id} cannot dispatch while descendants are non-terminal"
|
|
|
+ )
|
|
|
active = [
|
|
|
ledger.attempts[x] for x in task.attempt_ids
|
|
|
if ledger.attempts[x].status == AttemptStatus.RUNNING
|
|
|
@@ -1017,6 +1152,7 @@ class TaskCoordinator:
|
|
|
if attempt.execution_mode == "repair"
|
|
|
else None
|
|
|
),
|
|
|
+ "accepted_child_results": self._accepted_child_results(ledger, task),
|
|
|
"operation_id": operation_id,
|
|
|
"execution_epoch": execution_epoch,
|
|
|
"deadline": deadline,
|
|
|
@@ -2040,6 +2176,7 @@ class TaskCoordinator:
|
|
|
def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
task = _task(ledger, task_id)
|
|
|
before = task.status
|
|
|
+ is_root = task.task_id == ledger.root_task_id
|
|
|
validation = ledger.validations.get(validation_id) if validation_id else None
|
|
|
attempt_id = validation.attempt_id if validation else (task.attempt_ids[-1] if task.attempt_ids else None)
|
|
|
reason = str(payload.get("reason", "")).strip()
|
|
|
@@ -2064,19 +2201,24 @@ class TaskCoordinator:
|
|
|
elif action == DecisionAction.REVISE:
|
|
|
if task.status not in (TaskStatus.AWAITING_DECISION, TaskStatus.NEEDS_REPLAN):
|
|
|
raise TaskConflict("Revise requires awaiting_decision or needs_replan")
|
|
|
- objective = str(payload.get("objective") or task.current_spec.objective).strip()
|
|
|
- criteria_data = payload.get("acceptance_criteria")
|
|
|
- criteria = (
|
|
|
- [AcceptanceCriterion.from_dict(x) for x in criteria_data]
|
|
|
- if criteria_data is not None else list(task.current_spec.acceptance_criteria)
|
|
|
+ objective = (
|
|
|
+ payload["objective"]
|
|
|
+ if "objective" in payload
|
|
|
+ else task.current_spec.objective
|
|
|
)
|
|
|
version = task.current_spec_version + 1
|
|
|
- task.specs.append(TaskSpec(
|
|
|
- version=version,
|
|
|
- objective=objective,
|
|
|
- acceptance_criteria=criteria,
|
|
|
- context_refs=list(payload.get("context_refs", task.current_spec.context_refs)),
|
|
|
- ))
|
|
|
+ draft = {
|
|
|
+ "objective": objective,
|
|
|
+ "acceptance_criteria": (
|
|
|
+ payload["acceptance_criteria"]
|
|
|
+ if "acceptance_criteria" in payload
|
|
|
+ else [_plain(asdict(x)) for x in task.current_spec.acceptance_criteria]
|
|
|
+ ),
|
|
|
+ "context_refs": payload.get(
|
|
|
+ "context_refs", task.current_spec.context_refs
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ task.specs.append(self._task_spec_from_draft(draft, version=version))
|
|
|
task.current_spec_version = version
|
|
|
target = TaskStatus.PENDING
|
|
|
elif action == DecisionAction.SPLIT:
|
|
|
@@ -2091,6 +2233,11 @@ class TaskCoordinator:
|
|
|
elif action == DecisionAction.BLOCK:
|
|
|
if task.status in TERMINAL_TASK_STATUSES:
|
|
|
raise TaskConflict("Terminal task cannot be blocked")
|
|
|
+ if any(
|
|
|
+ child.status in ACTIVE_TASK_STATUSES
|
|
|
+ for child in self._nonterminal_descendants(ledger, task)
|
|
|
+ ):
|
|
|
+ raise TaskConflict("Task with active descendants cannot be blocked")
|
|
|
task.blocked_reason = reason
|
|
|
target = TaskStatus.BLOCKED
|
|
|
elif action == DecisionAction.UNBLOCK:
|
|
|
@@ -2099,12 +2246,20 @@ class TaskCoordinator:
|
|
|
task.blocked_reason = None
|
|
|
target = TaskStatus.NEEDS_REPLAN
|
|
|
elif action == DecisionAction.CANCEL:
|
|
|
+ if is_root:
|
|
|
+ raise TaskConflict("The root task cannot be cancelled")
|
|
|
if task.status in TERMINAL_TASK_STATUSES:
|
|
|
raise TaskConflict("Task is already terminal")
|
|
|
+ if self._nonterminal_descendants(ledger, task):
|
|
|
+ raise TaskConflict("Task with non-terminal descendants cannot be cancelled")
|
|
|
target = TaskStatus.CANCELLED
|
|
|
elif action == DecisionAction.SUPERSEDE:
|
|
|
+ if is_root:
|
|
|
+ raise TaskConflict("The root task cannot be superseded")
|
|
|
if task.status in TERMINAL_TASK_STATUSES:
|
|
|
raise TaskConflict("Task is already terminal")
|
|
|
+ if self._nonterminal_descendants(ledger, task):
|
|
|
+ raise TaskConflict("Task with non-terminal descendants cannot be superseded")
|
|
|
replacement = payload.get("replacement") or {}
|
|
|
replacement_ids = self._create_sibling_records(ledger, task, [replacement])
|
|
|
task.superseded_by = replacement_ids[0]
|
|
|
@@ -2191,6 +2346,8 @@ class TaskCoordinator:
|
|
|
attempt = ledger.attempts[validation.attempt_id]
|
|
|
if validation.snapshot_id != attempt.snapshot_id:
|
|
|
raise TaskConflict("Validation does not belong to the current snapshot")
|
|
|
+ if TaskCoordinator._nonterminal_descendants(ledger, task):
|
|
|
+ raise TaskConflict("Task cannot be accepted while descendants are non-terminal")
|
|
|
|
|
|
@staticmethod
|
|
|
def _guard_replan_decision(
|
|
|
@@ -2518,6 +2675,100 @@ class TaskCoordinator:
|
|
|
"recommendation": report.recommendation,
|
|
|
}
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _accepted_child_results(
|
|
|
+ ledger: TaskLedger,
|
|
|
+ task: TaskRecord,
|
|
|
+ ) -> List[Dict[str, Any]]:
|
|
|
+ """Build immutable, accepted direct-child inputs for a parent Worker."""
|
|
|
+
|
|
|
+ results: List[Dict[str, Any]] = []
|
|
|
+ children = sorted(
|
|
|
+ (ledger.tasks[child_id] for child_id in task.child_task_ids),
|
|
|
+ key=lambda child: _path_key(child.display_path),
|
|
|
+ )
|
|
|
+ for child in children:
|
|
|
+ if child.status != TaskStatus.COMPLETED:
|
|
|
+ continue
|
|
|
+ _, attempt, validation = TaskCoordinator._accepted_result_binding(
|
|
|
+ ledger, child
|
|
|
+ )
|
|
|
+ results.append({
|
|
|
+ "task_id": child.task_id,
|
|
|
+ "task_spec": _plain(asdict(child.current_spec)),
|
|
|
+ "attempt_id": attempt.attempt_id,
|
|
|
+ "snapshot_id": attempt.snapshot_id,
|
|
|
+ "submission": _plain(asdict(attempt.submission)),
|
|
|
+ "validation": {
|
|
|
+ "validation_id": validation.validation_id,
|
|
|
+ "summary": validation.summary,
|
|
|
+ "evidence_refs": _plain(asdict(validation)["evidence_refs"]),
|
|
|
+ },
|
|
|
+ })
|
|
|
+ return results
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _root_task(ledger: TaskLedger) -> TaskRecord:
|
|
|
+ if not ledger.root_task_id:
|
|
|
+ raise TaskConflict(
|
|
|
+ "Rootless task ledger is incompatible with mission execution; "
|
|
|
+ "rebuild the development trace"
|
|
|
+ )
|
|
|
+ root = ledger.tasks.get(ledger.root_task_id)
|
|
|
+ top_level = [task.task_id for task in ledger.tasks.values() if task.parent_task_id is None]
|
|
|
+ if not root or root.parent_task_id is not None or top_level != [root.task_id]:
|
|
|
+ raise OrchestrationError("Task ledger Root Task structure is invalid")
|
|
|
+ return root
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _accepted_result_binding(
|
|
|
+ ledger: TaskLedger,
|
|
|
+ task: TaskRecord,
|
|
|
+ ) -> Tuple[PlannerDecision, TaskAttempt, ValidationReport]:
|
|
|
+ if task.status != TaskStatus.COMPLETED or not task.decision_ids:
|
|
|
+ raise OrchestrationError(f"Task {task.task_id} has no accepted result")
|
|
|
+ decision = ledger.decisions.get(task.decision_ids[-1])
|
|
|
+ if (
|
|
|
+ not decision
|
|
|
+ or decision.task_id != task.task_id
|
|
|
+ or decision.action != DecisionAction.ACCEPT
|
|
|
+ or not decision.attempt_id
|
|
|
+ or not decision.validation_id
|
|
|
+ ):
|
|
|
+ raise OrchestrationError(f"Task {task.task_id} has an invalid accept decision")
|
|
|
+ attempt = ledger.attempts.get(decision.attempt_id)
|
|
|
+ validation = ledger.validations.get(decision.validation_id)
|
|
|
+ if (
|
|
|
+ not attempt
|
|
|
+ or attempt.task_id != task.task_id
|
|
|
+ or not attempt.submission
|
|
|
+ or not attempt.snapshot_id
|
|
|
+ or attempt.spec_version != task.current_spec_version
|
|
|
+ or not validation
|
|
|
+ or validation.task_id != task.task_id
|
|
|
+ or validation.attempt_id != attempt.attempt_id
|
|
|
+ or validation.snapshot_id != attempt.snapshot_id
|
|
|
+ or validation.spec_version != task.current_spec_version
|
|
|
+ or validation.status != ValidationRunStatus.COMPLETED
|
|
|
+ or validation.verdict != ValidationVerdict.PASSED
|
|
|
+ ):
|
|
|
+ raise OrchestrationError(f"Task {task.task_id} has an invalid accepted result binding")
|
|
|
+ return decision, attempt, validation
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _nonterminal_descendants(
|
|
|
+ ledger: TaskLedger,
|
|
|
+ task: TaskRecord,
|
|
|
+ ) -> List[TaskRecord]:
|
|
|
+ descendants: List[TaskRecord] = []
|
|
|
+ pending_ids = list(task.child_task_ids)
|
|
|
+ while pending_ids:
|
|
|
+ child = ledger.tasks[pending_ids.pop()]
|
|
|
+ pending_ids.extend(child.child_task_ids)
|
|
|
+ if child.status not in TERMINAL_TASK_STATUSES:
|
|
|
+ descendants.append(child)
|
|
|
+ return descendants
|
|
|
+
|
|
|
def _create_child_records(
|
|
|
self,
|
|
|
ledger: TaskLedger,
|
|
|
@@ -2542,19 +2793,8 @@ class TaskCoordinator:
|
|
|
parent_task_id: Optional[str],
|
|
|
display_path: str,
|
|
|
) -> str:
|
|
|
- objective = str(draft.get("objective", "")).strip()
|
|
|
- if not objective:
|
|
|
- raise ValueError("Task objective is required")
|
|
|
task_id = new_id()
|
|
|
- spec = TaskSpec(
|
|
|
- version=1,
|
|
|
- objective=objective,
|
|
|
- acceptance_criteria=[
|
|
|
- AcceptanceCriterion.from_dict(item)
|
|
|
- for item in draft.get("acceptance_criteria", [])
|
|
|
- ],
|
|
|
- context_refs=list(draft.get("context_refs", [])),
|
|
|
- )
|
|
|
+ spec = TaskCoordinator._task_spec_from_draft(draft, version=1)
|
|
|
ledger.tasks[task_id] = TaskRecord(
|
|
|
task_id=task_id,
|
|
|
goal_id=None,
|
|
|
@@ -2564,6 +2804,17 @@ class TaskCoordinator:
|
|
|
)
|
|
|
return task_id
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _task_spec_from_draft(draft: Dict[str, Any], *, version: int) -> TaskSpec:
|
|
|
+ if not isinstance(draft, dict):
|
|
|
+ raise ValueError("Task draft must be an object")
|
|
|
+ return TaskSpec.from_dict({
|
|
|
+ "version": version,
|
|
|
+ "objective": draft.get("objective", ""),
|
|
|
+ "acceptance_criteria": draft.get("acceptance_criteria", []),
|
|
|
+ "context_refs": draft.get("context_refs", ()),
|
|
|
+ })
|
|
|
+
|
|
|
def _create_records(
|
|
|
self,
|
|
|
ledger: TaskLedger,
|
|
|
@@ -2590,22 +2841,25 @@ class TaskCoordinator:
|
|
|
created.append(task_id)
|
|
|
return created
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _mark_parent_waiting(parent: TaskRecord) -> None:
|
|
|
+ if parent.status == TaskStatus.WAITING_CHILDREN:
|
|
|
+ return
|
|
|
+ transition(parent.status, TaskStatus.WAITING_CHILDREN)
|
|
|
+ parent.status = TaskStatus.WAITING_CHILDREN
|
|
|
+ parent.updated_at = utc_now()
|
|
|
+
|
|
|
@staticmethod
|
|
|
def _update_parent_after_child(ledger: TaskLedger, child: TaskRecord) -> None:
|
|
|
if not child.parent_task_id or child.status not in TERMINAL_TASK_STATUSES:
|
|
|
return
|
|
|
parent = ledger.tasks[child.parent_task_id]
|
|
|
+ if parent.status != TaskStatus.WAITING_CHILDREN:
|
|
|
+ return
|
|
|
children = [ledger.tasks[x] for x in parent.child_task_ids]
|
|
|
- if child.status in (TaskStatus.CANCELLED, TaskStatus.SUPERSEDED):
|
|
|
- if parent.status == TaskStatus.WAITING_CHILDREN:
|
|
|
- transition(parent.status, TaskStatus.NEEDS_REPLAN)
|
|
|
- parent.status = TaskStatus.NEEDS_REPLAN
|
|
|
- elif children and all(x.status == TaskStatus.COMPLETED for x in children):
|
|
|
- if parent.status == TaskStatus.WAITING_CHILDREN:
|
|
|
- transition(parent.status, TaskStatus.NEEDS_REPLAN)
|
|
|
- parent.status = TaskStatus.NEEDS_REPLAN
|
|
|
- elif any(x.status in {TaskStatus.RUNNING, TaskStatus.VALIDATING, TaskStatus.BLOCKED, TaskStatus.PENDING} for x in children):
|
|
|
- parent.status = TaskStatus.WAITING_CHILDREN
|
|
|
+ if children and all(x.status in TERMINAL_TASK_STATUSES for x in children):
|
|
|
+ transition(parent.status, TaskStatus.NEEDS_REPLAN)
|
|
|
+ parent.status = TaskStatus.NEEDS_REPLAN
|
|
|
parent.updated_at = utc_now()
|
|
|
|
|
|
async def project_goal_state(self, root_trace_id: str, task_id: str) -> None:
|
|
|
@@ -2759,6 +3013,20 @@ class TaskCoordinator:
|
|
|
return str(index)
|
|
|
return f"{ledger.tasks[parent_task_id].display_path}.{index}"
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _rebase_task_path(
|
|
|
+ ledger: TaskLedger,
|
|
|
+ task: TaskRecord,
|
|
|
+ display_path: str,
|
|
|
+ ) -> None:
|
|
|
+ task.display_path = display_path
|
|
|
+ for index, child_id in enumerate(task.child_task_ids, start=1):
|
|
|
+ TaskCoordinator._rebase_task_path(
|
|
|
+ ledger,
|
|
|
+ ledger.tasks[child_id],
|
|
|
+ f"{display_path}.{index}",
|
|
|
+ )
|
|
|
+
|
|
|
|
|
|
def _task(ledger: TaskLedger, task_id: str) -> TaskRecord:
|
|
|
try:
|