Files
agentic-mobile-control/workflow/runner.py
T
q792602257 a15756835c fix(workflow-orchestration): correct resume and failure-branch routing in WorkflowRunner
- Resume no longer infinite-loops when a crash occurs between
  append_step_result() and update_run() for a BranchStep: the recorded
  branch target is now read from the persisted result instead of
  recomputed as None.
- Resume no longer silently discards a recorded step failure that
  happened right before the crash; the run correctly ends failed.
- A failed step's own next_step_id (e.g. routing to a BranchStep that
  evaluates step_result_success) is now honored instead of
  unconditionally forcing the run to failed, per spec.

openspec: workflow-orchestration capability, archived change workflow-orchestration-runtime
2026-07-07 08:30:43 +08:00

346 lines
12 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from time import sleep
from core.models import Scene, Task
from runtime.task import TaskRunner
from skills_learning.store import SkillStore, get_default_store
from workflow.conditions import (
ConditionEvaluator,
UnknownConditionKindError,
evaluate_condition,
)
from workflow.config import WorkflowConfig, load_config
from workflow.models import (
BranchStep,
PlannedGoalStep,
SkillInvocationStep,
WaitForConditionStep,
WorkflowDefinition,
WorkflowRun,
WorkflowStep,
WorkflowStepResult,
)
from workflow.skill_exec import SkillExecutionError, run_flow_template_skill
from workflow.store import WorkflowStore
TaskRunnerFactory = Callable[[], TaskRunner]
SceneProvider = Callable[[], Scene | None]
WorldStateProvider = Callable[[], object | None]
SleepFunc = Callable[[float], None]
TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
class WorkflowRunner:
def __init__(
self,
store: WorkflowStore | None = None,
*,
task_runner_factory: TaskRunnerFactory | None = None,
skill_store: SkillStore | None = None,
tools: dict[str, Callable[..., Any]] | None = None,
condition_registry: dict[str, ConditionEvaluator] | None = None,
scene_provider: SceneProvider | None = None,
world_state_provider: WorldStateProvider | None = None,
sleep_func: SleepFunc = sleep,
config: WorkflowConfig | None = None,
step_limit: int | None = None,
) -> None:
self.config = config or load_config()
self.store = store or WorkflowStore(self.config.db_path)
self.task_runner_factory = task_runner_factory or (lambda: TaskRunner())
self.skill_store = skill_store or get_default_store()
self.tools = tools
self.condition_registry = condition_registry
self.scene_provider = scene_provider or (lambda: None)
self.world_state_provider = world_state_provider
self.sleep_func = sleep_func
self.step_limit = step_limit
def run(
self,
definition: WorkflowDefinition,
device_id: str,
initial_variables: dict[str, Any] | None = None,
) -> WorkflowRun:
self.store.save_definition(definition)
run = self.store.create_run(
definition.id,
initial_variables or {},
device_id=device_id,
)
return self._drive(definition, run)
def resume(self, run_id: str) -> WorkflowRun:
run = self.store.get_run(run_id)
if run is None:
raise KeyError(f"unknown workflow run {run_id}")
if run.status in TERMINAL_STATUSES:
return run
definition = self.store.get_definition(run.definition_id)
if definition is None:
raise KeyError(f"unknown workflow definition {run.definition_id}")
return self._drive(definition, run)
def _drive(
self,
definition: WorkflowDefinition,
run: WorkflowRun,
) -> WorkflowRun:
executed = 0
while run.status not in TERMINAL_STATUSES and run.current_step_id:
if self.step_limit is not None and executed >= self.step_limit:
return run
step = definition.step_by_id(run.current_step_id)
recorded = _recorded_result(run, step.step_id)
if recorded is not None:
branch_next_step_id = None
if isinstance(step, BranchStep):
branch_next_step_id = recorded.detail.get("next_step_id")
next_status, next_step_id = self._resolve_outcome(
definition, step, recorded.success, branch_next_step_id
)
run = self._checkpoint(run, next_step_id, next_status)
continue
result, branch_next_step_id = self._execute_step(definition, run, step)
next_status, next_step_id = self._resolve_outcome(
definition, step, result.success, branch_next_step_id
)
self.store.append_step_result(run.id, result)
run = self._checkpoint(
run,
next_step_id,
next_status,
)
executed += 1
return run
def _resolve_outcome(
self,
definition: WorkflowDefinition,
step: WorkflowStep,
success: bool,
branch_next_step_id: str | None,
) -> tuple[str, str | None]:
"""Compute the (status, next_step_id) for a step's outcome.
Shared by live execution and resume so a failed step's routing
(e.g. to a branch step evaluating the failure) can never drift
between the two code paths. The run only becomes terminal when
there is truly no next step to route to.
"""
next_step_id = self._next_step_id(definition, step, branch_next_step_id)
if next_step_id is None:
return ("completed" if success else "failed"), None
return "running", next_step_id
def _checkpoint(
self,
run: WorkflowRun,
current_step_id: str | None,
status: str,
) -> WorkflowRun:
self.store.update_run(
run.id,
status=status, # type: ignore[arg-type]
current_step_id=current_step_id,
variables=run.variables,
)
updated = self.store.get_run(run.id)
if updated is None:
raise KeyError(f"unknown workflow run {run.id}")
return updated
def _execute_step(
self,
definition: WorkflowDefinition,
run: WorkflowRun,
step: WorkflowStep,
) -> tuple[WorkflowStepResult, str | None]:
if isinstance(step, PlannedGoalStep):
return self._execute_planned_goal_step(run, step), None
if isinstance(step, SkillInvocationStep):
return self._execute_skill_invocation_step(step), None
if isinstance(step, WaitForConditionStep):
return self._execute_wait_step(run, step), None
if isinstance(step, BranchStep):
return self._execute_branch_step(run, step)
return (
WorkflowStepResult(
step_id=getattr(step, "step_id", "unknown"),
kind="unknown",
success=False,
detail={"reason": "unknown workflow step type"},
),
None,
)
def _execute_planned_goal_step(
self,
run: WorkflowRun,
step: PlannedGoalStep,
) -> WorkflowStepResult:
task = Task(goal=step.goal, device_id=run.device_id or "")
task_runner = self.task_runner_factory()
result_task = task_runner.run(task)
success = result_task.status == "completed"
return WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=success,
detail={
"task_status": result_task.status,
"failure_reason": result_task.failure_reason,
},
task_id=result_task.id,
)
def _execute_skill_invocation_step(
self,
step: SkillInvocationStep,
) -> WorkflowStepResult:
skill = self.skill_store.get_by_id(step.skill_id)
if skill is None:
return WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=False,
detail={"reason": f"unknown skill {step.skill_id}"},
)
try:
results = run_flow_template_skill(skill, step.args, tools=self.tools)
except SkillExecutionError as exc:
return WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=False,
detail={"reason": str(exc)},
)
success = all(result.success for result in results)
return WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=success,
detail={
"step_results": [result.to_dict() for result in results],
},
)
def _execute_wait_step(
self,
run: WorkflowRun,
step: WaitForConditionStep,
) -> WorkflowStepResult:
started_at = datetime.now().astimezone()
timeout_seconds = step.timeout_seconds
poll_interval_seconds = step.poll_interval_seconds
while True:
try:
if self._condition_is_true(run, step.condition, started_at=started_at):
return WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=True,
detail={"condition": step.condition.to_dict()},
)
except UnknownConditionKindError as exc:
return WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=False,
detail={"reason": f"unknown condition kind: {exc}"},
)
elapsed = (
datetime.now(started_at.tzinfo) - started_at
).total_seconds()
if elapsed >= timeout_seconds:
return WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=False,
detail={"reason": "condition timed out"},
)
self.sleep_func(poll_interval_seconds)
def _execute_branch_step(
self,
run: WorkflowRun,
step: BranchStep,
) -> tuple[WorkflowStepResult, str | None]:
try:
matched = self._condition_is_true(run, step.condition)
except UnknownConditionKindError as exc:
return (
WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=False,
detail={"reason": f"unknown condition kind: {exc}"},
),
None,
)
target = step.on_true if matched else step.on_false
return (
WorkflowStepResult(
step_id=step.step_id,
kind=step.kind,
success=True,
detail={"condition_result": matched, "next_step_id": target},
),
target,
)
def _condition_is_true(
self,
run: WorkflowRun,
condition,
*,
started_at: datetime | None = None,
) -> bool:
return evaluate_condition(
condition,
scene=self.scene_provider(),
world_state=self._world_state(run),
started_at=started_at,
step_results=run.step_results,
registry=self.condition_registry,
)
def _world_state(self, run: WorkflowRun) -> object:
if self.world_state_provider is not None:
world_state = self.world_state_provider()
if world_state is not None:
return world_state
return SimpleNamespace(variables=dict(run.variables))
def _next_step_id(
self,
definition: WorkflowDefinition,
step: WorkflowStep,
branch_next_step_id: str | None,
) -> str | None:
if branch_next_step_id is not None:
return branch_next_step_id
explicit = getattr(step, "next_step_id", None)
if explicit is not None:
return explicit
if isinstance(step, BranchStep):
return None
return definition.next_step_id_after(step.step_id)
def _recorded_result(run: WorkflowRun, step_id: str) -> WorkflowStepResult | None:
for result in reversed(run.step_results):
if result.step_id == step_id:
return result
return None