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:
+16
-6
@@ -40,9 +40,15 @@ Observer = Callable[[str], Scene]
|
||||
ScreenshotProvider = Callable[[str], bytes]
|
||||
TaskSucceededHook = Callable[[str, str, Timeline], None]
|
||||
StopRequested = Callable[[], bool]
|
||||
StopReason = Callable[[], "str | None"]
|
||||
StepProgressCallback = Callable[[int, str, str], None]
|
||||
|
||||
|
||||
def is_cancellation_reason(reason: str | None) -> bool:
|
||||
"""Distinguish an explicit cancellation stop from other stop reasons (e.g. lost lease)."""
|
||||
return bool(reason) and "cancel" in reason.lower()
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -98,6 +104,7 @@ class TaskRunner:
|
||||
task: Task,
|
||||
*,
|
||||
should_stop: StopRequested | None = None,
|
||||
stop_reason: StopReason | None = None,
|
||||
) -> Task:
|
||||
if self.metadata_store:
|
||||
self.metadata_store.create_task(task)
|
||||
@@ -109,7 +116,7 @@ class TaskRunner:
|
||||
|
||||
for _ in range(self.config.max_steps):
|
||||
if should_stop is not None and should_stop():
|
||||
return self._interrupt_task(task)
|
||||
return self._interrupt_task(task, stop_reason)
|
||||
try:
|
||||
scene = self.observer(task.device_id)
|
||||
context.add_scene(scene)
|
||||
@@ -136,7 +143,7 @@ class TaskRunner:
|
||||
|
||||
for step_index, step in enumerate(steps):
|
||||
if should_stop is not None and should_stop():
|
||||
return self._interrupt_task(task)
|
||||
return self._interrupt_task(task, stop_reason)
|
||||
executable_step = self._step_for_device(step, task.device_id)
|
||||
if step_index == 0 and screenshot is not None:
|
||||
# Reuse the screenshot already captured for planning instead of
|
||||
@@ -194,13 +201,16 @@ class TaskRunner:
|
||||
)
|
||||
return task
|
||||
|
||||
def _interrupt_task(self, task: Task) -> Task:
|
||||
self._emit_step_progress(-1, "failed", "execution interrupted")
|
||||
def _interrupt_task(self, task: Task, stop_reason: StopReason | None = None) -> Task:
|
||||
reason = stop_reason() if stop_reason is not None else None
|
||||
message = reason or "execution interrupted"
|
||||
status = "cancelled" if is_cancellation_reason(reason) else "failed"
|
||||
self._emit_step_progress(-1, "failed", message)
|
||||
self._update_task(
|
||||
task,
|
||||
status="failed",
|
||||
status=status,
|
||||
completed=True,
|
||||
failure_reason="execution interrupted",
|
||||
failure_reason=message,
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
Reference in New Issue
Block a user