feat(runtime): add cancellation-aware stop_reason to TaskRunner and WorkflowRunner

- TaskRunner.run() and WorkflowRunner.run()/resume() accept an optional
  stop_reason callable alongside should_stop, distinguishing a genuine
  cancellation from other stop conditions (e.g. lost lease).
- is_cancellation_reason() shared helper added to runtime/task.py.
- WorkflowRunner._stop_status() now branches cancelled/failed based on
  stop_reason, correcting a prior blanket cancelled-on-any-stop behavior
  that conflicted with the host-agent-protocol spec's requirement to
  distinguish cancellation from lease-loss stops.
- Default behavior (stop_reason=None) is preserved exactly for both
  runners so existing callers/tests are unaffected.
- Task 1 of openspec change task-cancellation.
This commit is contained in:
2026-07-15 17:52:33 +08:00
parent 947434b65a
commit 88189770ff
5 changed files with 199 additions and 17 deletions
+32 -6
View File
@@ -7,7 +7,7 @@ from typing import Any
from time import sleep
from core.models import Scene, Task
from runtime.task import TaskRunner
from runtime.task import StopReason, TaskRunner, is_cancellation_reason
from skills_learning.store import SkillStore, get_default_store
from workflow.conditions import (
ConditionEvaluator,
@@ -70,6 +70,7 @@ class WorkflowRunner:
initial_variables: dict[str, Any] | None = None,
*,
should_stop: StopRequested | None = None,
stop_reason: StopReason | None = None,
) -> WorkflowRun:
self.store.save_definition(definition)
run = self.store.create_run(
@@ -77,13 +78,14 @@ class WorkflowRunner:
initial_variables or {},
device_id=device_id,
)
return self._drive(definition, run, should_stop=should_stop)
return self._drive(definition, run, should_stop=should_stop, stop_reason=stop_reason)
def resume(
self,
run_id: str,
*,
should_stop: StopRequested | None = None,
stop_reason: StopReason | None = None,
) -> WorkflowRun:
run = self.store.get_run(run_id)
if run is None:
@@ -93,7 +95,18 @@ class WorkflowRunner:
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, should_stop=should_stop)
return self._drive(definition, run, should_stop=should_stop, stop_reason=stop_reason)
def _stop_status(self, stop_reason: StopReason | None) -> str:
"""Resolve the terminal status for a should_stop-triggered stop.
No `stop_reason` preserves the pre-existing default of `cancelled` for
any stop; a supplied reason distinguishes an explicit cancellation
from other stop conditions (e.g. lost lease), which resolve to `failed`.
"""
if stop_reason is None:
return "cancelled"
return "cancelled" if is_cancellation_reason(stop_reason()) else "failed"
def _drive(
self,
@@ -101,11 +114,14 @@ class WorkflowRunner:
run: WorkflowRun,
*,
should_stop: StopRequested | None = None,
stop_reason: StopReason | None = None,
) -> WorkflowRun:
executed = 0
while run.status not in TERMINAL_STATUSES and run.current_step_id:
if should_stop is not None and should_stop():
return self._checkpoint(run, run.current_step_id, "cancelled")
return self._checkpoint(
run, run.current_step_id, self._stop_status(stop_reason)
)
if self.step_limit is not None and executed >= self.step_limit:
return run
step = definition.step_by_id(run.current_step_id)
@@ -125,10 +141,13 @@ class WorkflowRunner:
run,
step,
should_stop=should_stop,
stop_reason=stop_reason,
)
if should_stop is not None and should_stop():
self.store.append_step_result(run.id, result)
return self._checkpoint(run, run.current_step_id, "cancelled")
return self._checkpoint(
run, run.current_step_id, self._stop_status(stop_reason)
)
next_status, next_step_id = self._resolve_outcome(
definition, step, result.success, branch_next_step_id
)
@@ -185,12 +204,14 @@ class WorkflowRunner:
step: WorkflowStep,
*,
should_stop: StopRequested | None = None,
stop_reason: StopReason | None = None,
) -> tuple[WorkflowStepResult, str | None]:
if isinstance(step, PlannedGoalStep):
return self._execute_planned_goal_step(
run,
step,
should_stop=should_stop,
stop_reason=stop_reason,
), None
if isinstance(step, SkillInvocationStep):
return self._execute_skill_invocation_step(step), None
@@ -218,13 +239,18 @@ class WorkflowRunner:
step: PlannedGoalStep,
*,
should_stop: StopRequested | None = None,
stop_reason: StopReason | None = None,
) -> WorkflowStepResult:
task = Task(goal=step.goal, device_id=run.device_id or "")
task_runner = self.task_runner_factory()
if should_stop is None:
result_task = task_runner.run(task)
else:
elif stop_reason is None:
result_task = task_runner.run(task, should_stop=should_stop)
else:
result_task = task_runner.run(
task, should_stop=should_stop, stop_reason=stop_reason
)
success = result_task.status == "completed"
return WorkflowStepResult(
step_id=step.step_id,