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
This commit is contained in:
2026-07-07 08:30:43 +08:00
parent b5e68398f8
commit a15756835c
2 changed files with 176 additions and 12 deletions
+36 -12
View File
@@ -98,19 +98,21 @@ class WorkflowRunner:
if self.step_limit is not None and executed >= self.step_limit:
return run
step = definition.step_by_id(run.current_step_id)
if _already_recorded(run, step.step_id):
next_step_id = self._next_step_id(definition, step, None)
run = self._checkpoint(run, next_step_id, "running")
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 = "running"
next_step_id = self._next_step_id(definition, step, branch_next_step_id)
if not result.success:
next_status = "failed"
next_step_id = None
elif next_step_id is None:
next_status = "completed"
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(
@@ -121,6 +123,25 @@ class WorkflowRunner:
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,
@@ -317,5 +338,8 @@ class WorkflowRunner:
return definition.next_step_id_after(step.step_id)
def _already_recorded(run: WorkflowRun, step_id: str) -> bool:
return any(result.step_id == step_id for result in run.step_results)
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