|
|
@@ -222,8 +222,8 @@ class TaskCoordinator:
|
|
|
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."""
|
|
|
+ async def root_completion(self, root_trace_id: str) -> Dict[str, Any]:
|
|
|
+ """Return Root Task completion without persisting a second state machine."""
|
|
|
|
|
|
ledger = await self.task_store.load(root_trace_id)
|
|
|
root = self._root_task(ledger)
|
|
|
@@ -248,12 +248,18 @@ class TaskCoordinator:
|
|
|
]
|
|
|
return {
|
|
|
"root_task_id": root.task_id,
|
|
|
+ "root_objective": ledger.root_objective,
|
|
|
"status": root.status.value,
|
|
|
"blocked_reason": root.blocked_reason,
|
|
|
"result_summary": result_summary,
|
|
|
"pending_tasks": pending,
|
|
|
}
|
|
|
|
|
|
+ async def mission_completion(self, root_trace_id: str) -> Dict[str, Any]:
|
|
|
+ """Compatibility alias for callers using the pre-Root terminology."""
|
|
|
+
|
|
|
+ return await self.root_completion(root_trace_id)
|
|
|
+
|
|
|
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)
|
|
|
@@ -1053,6 +1059,9 @@ class TaskCoordinator:
|
|
|
worker_trace_id=worker_trace_id,
|
|
|
worker_preset=worker_preset,
|
|
|
execution_mode=execution_mode,
|
|
|
+ accepted_child_decision_ids=self._accepted_child_decision_ids(
|
|
|
+ ledger, task
|
|
|
+ ),
|
|
|
continue_from_trace_id=continue_trace_id,
|
|
|
operation_id=operation_id,
|
|
|
execution_epoch=execution_epoch,
|
|
|
@@ -1133,6 +1142,53 @@ class TaskCoordinator:
|
|
|
ledger = await self.task_store.load(root_trace_id)
|
|
|
task = ledger.tasks[task_id]
|
|
|
attempt = ledger.attempts[attempt_id]
|
|
|
+ if attempt.accepted_child_decision_ids is None:
|
|
|
+ accepted_child_results: List[Dict[str, Any]] = []
|
|
|
+ worker_result = WorkerRunResult(
|
|
|
+ trace_id=attempt.worker_trace_id,
|
|
|
+ status="failed",
|
|
|
+ error=(
|
|
|
+ "Legacy running attempt has no frozen child input binding"
|
|
|
+ ),
|
|
|
+ execution_stats=ExecutionStats(
|
|
|
+ failure_code=FailureCode.PROTOCOL_VIOLATION
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ try:
|
|
|
+ accepted_child_results = self._accepted_child_results(
|
|
|
+ ledger, task, attempt
|
|
|
+ )
|
|
|
+ for child_result in accepted_child_results:
|
|
|
+ snapshot = await self.artifact_store.get(
|
|
|
+ root_trace_id, child_result["snapshot_id"]
|
|
|
+ )
|
|
|
+ if snapshot.attempt_id != child_result["attempt_id"]:
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Accepted child snapshot does not belong to its attempt"
|
|
|
+ )
|
|
|
+ child_attempt = ledger.attempts[child_result["attempt_id"]]
|
|
|
+ if (
|
|
|
+ snapshot.snapshot_id != child_attempt.snapshot_id
|
|
|
+ or snapshot.artifact_refs
|
|
|
+ != child_attempt.submission.artifact_refs # type: ignore[union-attr]
|
|
|
+ or snapshot.evidence_refs
|
|
|
+ != child_attempt.submission.evidence_refs # type: ignore[union-attr]
|
|
|
+ ):
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Accepted child snapshot does not match its submission"
|
|
|
+ )
|
|
|
+ except Exception as exc:
|
|
|
+ worker_result = WorkerRunResult(
|
|
|
+ trace_id=attempt.worker_trace_id,
|
|
|
+ status="failed",
|
|
|
+ error=f"Invalid frozen child input binding: {exc}",
|
|
|
+ execution_stats=ExecutionStats(
|
|
|
+ failure_code=FailureCode.PROTOCOL_VIOLATION
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ worker_result = None
|
|
|
worker_context = {
|
|
|
"root_trace_id": root_trace_id,
|
|
|
"task_id": task_id,
|
|
|
@@ -1152,35 +1208,36 @@ class TaskCoordinator:
|
|
|
if attempt.execution_mode == "repair"
|
|
|
else None
|
|
|
),
|
|
|
- "accepted_child_results": self._accepted_child_results(ledger, task),
|
|
|
+ "accepted_child_results": accepted_child_results,
|
|
|
"operation_id": operation_id,
|
|
|
"execution_epoch": execution_epoch,
|
|
|
"deadline": deadline,
|
|
|
}
|
|
|
- try:
|
|
|
- worker_result = await asyncio.wait_for(
|
|
|
- self.executor.run_worker(worker_context), # type: ignore[union-attr]
|
|
|
- timeout=stage_timeout(
|
|
|
- deadline,
|
|
|
- self.config.worker_timeout_seconds,
|
|
|
- ),
|
|
|
- )
|
|
|
- except asyncio.TimeoutError:
|
|
|
- worker_result = WorkerRunResult(
|
|
|
- trace_id=attempt.worker_trace_id,
|
|
|
- status="expired",
|
|
|
- error="Worker execution timed out",
|
|
|
- execution_stats=ExecutionStats(failure_code=FailureCode.TIMEOUT),
|
|
|
- )
|
|
|
- except Exception as exc:
|
|
|
- worker_result = WorkerRunResult(
|
|
|
- trace_id=attempt.worker_trace_id,
|
|
|
- status="failed",
|
|
|
- error=f"Worker executor error: {exc}",
|
|
|
- execution_stats=ExecutionStats(
|
|
|
- failure_code=FailureCode.EXECUTOR_ERROR
|
|
|
- ),
|
|
|
- )
|
|
|
+ if worker_result is None:
|
|
|
+ try:
|
|
|
+ worker_result = await asyncio.wait_for(
|
|
|
+ self.executor.run_worker(worker_context), # type: ignore[union-attr]
|
|
|
+ timeout=stage_timeout(
|
|
|
+ deadline,
|
|
|
+ self.config.worker_timeout_seconds,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ except asyncio.TimeoutError:
|
|
|
+ worker_result = WorkerRunResult(
|
|
|
+ trace_id=attempt.worker_trace_id,
|
|
|
+ status="expired",
|
|
|
+ error="Worker execution timed out",
|
|
|
+ execution_stats=ExecutionStats(failure_code=FailureCode.TIMEOUT),
|
|
|
+ )
|
|
|
+ except Exception as exc:
|
|
|
+ worker_result = WorkerRunResult(
|
|
|
+ trace_id=attempt.worker_trace_id,
|
|
|
+ status="failed",
|
|
|
+ error=f"Worker executor error: {exc}",
|
|
|
+ execution_stats=ExecutionStats(
|
|
|
+ failure_code=FailureCode.EXECUTOR_ERROR
|
|
|
+ ),
|
|
|
+ )
|
|
|
else:
|
|
|
worker_result = None
|
|
|
|
|
|
@@ -2679,26 +2736,60 @@ class TaskCoordinator:
|
|
|
def _accepted_child_results(
|
|
|
ledger: TaskLedger,
|
|
|
task: TaskRecord,
|
|
|
+ attempt: Optional[TaskAttempt] = None,
|
|
|
) -> 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),
|
|
|
+ if attempt is None:
|
|
|
+ decision_ids = TaskCoordinator._accepted_child_decision_ids(ledger, task)
|
|
|
+ elif attempt.accepted_child_decision_ids is None:
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Attempt has no frozen accepted child decision binding"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ decision_ids = attempt.accepted_child_decision_ids
|
|
|
+ if len(set(decision_ids)) != len(decision_ids):
|
|
|
+ raise OrchestrationError("Attempt child decision bindings are not unique")
|
|
|
+ expected_decision_ids = TaskCoordinator._accepted_child_decision_ids(
|
|
|
+ ledger, task
|
|
|
)
|
|
|
- for child in children:
|
|
|
- if child.status != TaskStatus.COMPLETED:
|
|
|
- continue
|
|
|
- _, attempt, validation = TaskCoordinator._accepted_result_binding(
|
|
|
- ledger, child
|
|
|
+ if tuple(decision_ids) != expected_decision_ids:
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Attempt child decision bindings do not match direct children in display_path order"
|
|
|
+ )
|
|
|
+ direct_children = {child_id for child_id in task.child_task_ids}
|
|
|
+ prior_path: Optional[Tuple[int, ...]] = None
|
|
|
+ for decision_id in decision_ids:
|
|
|
+ decision = ledger.decisions.get(decision_id)
|
|
|
+ if (
|
|
|
+ decision is None
|
|
|
+ or decision.task_id not in direct_children
|
|
|
+ or decision.action != DecisionAction.ACCEPT
|
|
|
+ ):
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Attempt child decision binding is not a direct-child ACCEPT"
|
|
|
+ )
|
|
|
+ child = ledger.tasks[decision.task_id]
|
|
|
+ path = _path_key(child.display_path)
|
|
|
+ if prior_path is not None and path < prior_path:
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Attempt child decision bindings are not in display_path order"
|
|
|
+ )
|
|
|
+ prior_path = path
|
|
|
+ bound_decision, child_attempt, validation = (
|
|
|
+ TaskCoordinator._accepted_result_binding(ledger, child)
|
|
|
)
|
|
|
+ if bound_decision.decision_id != decision_id:
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Attempt child decision binding is not the child's accepted result"
|
|
|
+ )
|
|
|
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)),
|
|
|
+ "attempt_id": child_attempt.attempt_id,
|
|
|
+ "snapshot_id": child_attempt.snapshot_id,
|
|
|
+ "submission": _plain(asdict(child_attempt.submission)),
|
|
|
"validation": {
|
|
|
"validation_id": validation.validation_id,
|
|
|
"summary": validation.summary,
|
|
|
@@ -2707,6 +2798,24 @@ class TaskCoordinator:
|
|
|
})
|
|
|
return results
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _accepted_child_decision_ids(
|
|
|
+ ledger: TaskLedger,
|
|
|
+ task: TaskRecord,
|
|
|
+ ) -> Tuple[str, ...]:
|
|
|
+ decision_ids: List[str] = []
|
|
|
+ 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:
|
|
|
+ decision, _attempt, _validation = (
|
|
|
+ TaskCoordinator._accepted_result_binding(ledger, child)
|
|
|
+ )
|
|
|
+ decision_ids.append(decision.decision_id)
|
|
|
+ return tuple(decision_ids)
|
|
|
+
|
|
|
@staticmethod
|
|
|
def _root_task(ledger: TaskLedger) -> TaskRecord:
|
|
|
if not ledger.root_task_id:
|
|
|
@@ -2947,7 +3056,7 @@ class TaskCoordinator:
|
|
|
task = _task(ledger, task_id)
|
|
|
tree = await self.trace_store.get_goal_tree(root_trace_id)
|
|
|
if tree is None:
|
|
|
- tree = GoalTree(mission=ledger.mission)
|
|
|
+ tree = GoalTree(mission=ledger.root_objective)
|
|
|
if task.goal_id and tree.find(task.goal_id):
|
|
|
return
|
|
|
parent_goal_id = None
|