child graphs can interrupt/resume
This commit is contained in:
@@ -52,8 +52,10 @@ implementation state.
|
||||
reference conversion helpers. Core can now execute a prepared local child
|
||||
workflow through an isolated child scope/lineage, preserve its trace entries,
|
||||
map child output through the boundary, and route by the child's terminal
|
||||
outcome. Interrupt bubbling/resume and saved/deployed child resolution remain
|
||||
next. Wrapper helpers currently run child workflows as ordinary nodes; native
|
||||
outcome. Prepared child interrupts now bubble through a typed internal route
|
||||
and resume inside child scope while the public request identifies the parent
|
||||
subgraph boundary. Saved/deployed child resolution remains next. Wrapper
|
||||
helpers currently run child workflows as ordinary nodes; native
|
||||
`SubgraphNode` is now the graph-as-node path for prepared children.
|
||||
- **Concurrent foreach**: implemented in core with explicit scheduling,
|
||||
reducer/merge semantics, item error policy, async handler batching, and
|
||||
@@ -64,9 +66,9 @@ implementation state.
|
||||
`RuntimeScope` / `LineageState` storage, scope-aware reads, and non-root write
|
||||
buffering. New concurrent foreach item writes are stored in
|
||||
`RunState.lineages`, while `ForeachBarrierState` keeps scheduling/result
|
||||
metadata and compatibility patches. Current direct commits are still
|
||||
root-frame-only via an explicit helper; native subgraph completion should
|
||||
replace that shortcut with an explicit scope/lineage commit target.
|
||||
metadata and compatibility patches. Scope-root commits now apply to both the
|
||||
root workflow and prepared native child scopes through the explicit
|
||||
scope/lineage commit helper.
|
||||
- **Persistent run history**: add a run store before adding stable `run_id`,
|
||||
`inspect_run`, or `read_run_trace(run_id, range)` APIs. Current traces are
|
||||
returned directly from immediate run responses.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Native Subgraph Interrupt Resume Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let a prepared native child workflow interrupt its parent run and later resume inside the original child scope.
|
||||
|
||||
**Architecture:** Add a typed internal interrupt route that distinguishes the public parent-subgraph identity from the actual interrupted child frame. Make interrupt request/resume operations scope-aware, then have engine resume select the prepared child workflow and reducers before applying child resume bindings. The parent `SubgraphNode` remains blocked until the child reaches its ordinary terminal outcome.
|
||||
|
||||
**Tech Stack:** Python 3.14, dataclasses, Pydantic workflow models, pytest, Ruff, basedpyright.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Child Interrupt Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_core/run_state.py`
|
||||
- Modify: `tests/core/test_subgraph_step.py`
|
||||
|
||||
- [x] **Step 1: Write failing child interrupt/resume tests**
|
||||
|
||||
Add a child workflow containing an `InterruptNode` followed by a node or terminal step. Execute it through a parent `SubgraphNode` and assert:
|
||||
|
||||
```python
|
||||
assert run.status == RunStatus.INTERRUPTED
|
||||
assert run.interrupt is not None
|
||||
assert run.interrupt.node_id == "child"
|
||||
assert run.interrupt.payload["question"] == "confirm?"
|
||||
assert run.frames["root"].status == FrameStatus.BLOCKED
|
||||
|
||||
resumed = resume_workflow(
|
||||
parent,
|
||||
run,
|
||||
{},
|
||||
resume_payload={"answer": "yes"},
|
||||
subgraphs={"child.workflow": prepared_child},
|
||||
)
|
||||
assert resumed.status == RunStatus.COMPLETED
|
||||
assert resumed.output["answer"] == "yes"
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run tests and observe the existing explicit rejection**
|
||||
|
||||
Run: `uv run pytest -q tests/core/test_subgraph_step.py`
|
||||
|
||||
Expected: FAIL because child `InterruptNode` execution currently raises that child interrupts are unsupported.
|
||||
|
||||
- [x] **Step 3: Add structural route state**
|
||||
|
||||
Add `InterruptRoute` to `run_state.py` containing the interrupted child
|
||||
`frame_id`, `node_id`, `scope_id`, `lineage_id`, and `workflow_ref`. Add an
|
||||
optional `route` field to `InterruptRequest`; root interrupt requests continue
|
||||
to use `route=None`.
|
||||
|
||||
### Task 2: Scope-Aware Interrupt Creation and Resume
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_core/runtime/ops/handlers.py`
|
||||
- Modify: `src/wf_core/runtime/ops/interrupts.py`
|
||||
- Modify: `src/wf_core/runtime/preparation.py`
|
||||
- Modify: `src/wf_core/runtime/step.py`
|
||||
- Modify: `src/wf_core/runtime/engine.py`
|
||||
|
||||
- [x] **Step 1: Permit child interrupts and build their payload from child scope**
|
||||
|
||||
Remove the explicit child rejection. Build child interrupt request bindings
|
||||
from `state_view_for_frame(...)` and `scope_input_for_frame(...)`, not from the
|
||||
root compatibility dictionaries. For a non-root scope, find the owning parent
|
||||
subgraph frame for public identity and attach `InterruptRoute` for resume.
|
||||
|
||||
- [x] **Step 2: Resume through the routed child workflow**
|
||||
|
||||
When an interrupted request has `route`, restore the routed child as the
|
||||
current frame, resolve its `PreparedSubgraph`, build the child workflow index,
|
||||
and apply `resume` output bindings into the child scope using normal
|
||||
scope-aware patch commit logic. Root interrupts retain the existing path.
|
||||
|
||||
- [x] **Step 3: Verify parent completion behavior**
|
||||
|
||||
After child resume, scheduling must continue child execution first. Only after
|
||||
the child finishes may the blocked parent subgraph frame wake and map child
|
||||
output into parent state.
|
||||
|
||||
### Task 3: Verification and Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/wf_core_architecture.md`
|
||||
- Modify: `docs/current_roadmap.md`
|
||||
|
||||
- [x] **Step 1: Update documented limitations**
|
||||
|
||||
Document that prepared child interrupts now bubble and resume locally, while
|
||||
artifact/deployment resolution for nested children remains outside core.
|
||||
|
||||
- [x] **Step 2: Run focused verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
uv run pytest -q tests/core/test_subgraph_step.py tests/core/test_concurrent_foreach_interrupts.py
|
||||
uvx ruff check src/wf_core tests/core/test_subgraph_step.py tests/core/test_concurrent_foreach_interrupts.py
|
||||
uvx ruff format --check src/wf_core tests/core/test_subgraph_step.py tests/core/test_concurrent_foreach_interrupts.py
|
||||
uv run basedpyright --level error src/wf_core tests/core/test_subgraph_step.py tests/core/test_concurrent_foreach_interrupts.py
|
||||
```
|
||||
|
||||
Expected: all commands exit successfully.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Native Subgraphs Design
|
||||
|
||||
Status: prepared-child execution implemented; interrupts/artifact resolution planned
|
||||
Status: prepared-child execution and interrupt resume implemented; artifact resolution planned
|
||||
|
||||
Native subgraphs should make a workflow usable as a workflow step without
|
||||
collapsing the child run into one opaque Python node call. The current
|
||||
@@ -8,9 +8,9 @@ collapsing the child run into one opaque Python node call. The current
|
||||
compatibility wrappers, but they hide the child trace, child frames, and child
|
||||
interrupt lifecycle from `wf_core`.
|
||||
|
||||
This design defines the core runtime shape. The boundary model and
|
||||
non-interrupting prepared-child execution are implemented; interruption and
|
||||
saved-workflow resolution remain planned.
|
||||
This design defines the core runtime shape. The boundary model, prepared-child
|
||||
execution, and routed child interrupt resume are implemented; saved-workflow
|
||||
resolution remains planned.
|
||||
|
||||
## Goals
|
||||
|
||||
@@ -81,7 +81,8 @@ Runtime execution now accepts caller-supplied `PreparedSubgraph` dependencies
|
||||
for local workflow refs. It creates a child scope/lineage, schedules child
|
||||
frames in the parent run, retains child trace entries, and applies mapped
|
||||
output only at boundary completion. Saved artifact refs are not loaded by
|
||||
`wf_core`, and child interrupts fail explicitly until resume routing exists.
|
||||
`wf_core`. Prepared child interrupts bubble through a typed internal route and
|
||||
resume inside their original child scope.
|
||||
|
||||
`WorkflowRef` should be structural, not a dotted string parser:
|
||||
|
||||
@@ -228,15 +229,16 @@ If a child frame reaches an `InterruptNode`:
|
||||
4. `RunState.interrupt` points to the child interrupt, with enough route data
|
||||
to resume into the child.
|
||||
|
||||
The parent-facing interrupt request should include:
|
||||
The client-facing interrupt identity should remain the parent subgraph
|
||||
boundary: its frame/step identify the reusable graph node that asked for
|
||||
interaction, while `kind` and `payload` describe what the caller must supply.
|
||||
The runtime must separately retain the actual child route required for resume:
|
||||
|
||||
- parent frame id
|
||||
- subgraph parent step id
|
||||
- parent frame id and subgraph parent step id for the public request identity
|
||||
- child workflow reference
|
||||
- child frame id
|
||||
- child interrupt node id
|
||||
- interrupt kind
|
||||
- payload
|
||||
- child scope and lineage ids
|
||||
- interrupted child frame id and interrupt node id
|
||||
- interrupt kind and payload
|
||||
|
||||
Current `InterruptRequest` only has `id`, `frame_id`, `node_id`, `kind`,
|
||||
`payload`, and `resumable`. Native subgraphs need either:
|
||||
@@ -245,9 +247,11 @@ Current `InterruptRequest` only has `id`, `frame_id`, `node_id`, `kind`,
|
||||
`lineage_id`, `parent_frame_id`, and `workflow_ref`, or
|
||||
- a typed nested route object, such as `InterruptRoute`.
|
||||
|
||||
The preferred direction is explicit route structure. A generic metadata field
|
||||
would recreate the ad hoc frame metadata problem, while string parsing is
|
||||
exactly what the project has been moving away from.
|
||||
The preferred direction is a typed `InterruptRoute` stored by
|
||||
`InterruptRequest`. A generic metadata field would recreate the ad hoc frame
|
||||
metadata problem, while string parsing is exactly what the project has been
|
||||
moving away from. Root-workflow interrupts may omit the route and retain their
|
||||
existing direct frame/node identity.
|
||||
|
||||
## Resume Semantics
|
||||
|
||||
@@ -393,7 +397,7 @@ selection out of `wf_core`.
|
||||
- Artifact conversion helpers bridge saved workflow identities to core
|
||||
`WorkflowRef` values.
|
||||
|
||||
### Completed Slice 1: Non-Interrupting Prepared Subgraph Runtime
|
||||
### Completed Slice 1: Prepared Subgraph Runtime
|
||||
|
||||
- Local/prepared child `WorkflowRef` dependencies resolve through
|
||||
`PreparedSubgraph`; `wf_core` does not load saved artifacts.
|
||||
@@ -404,9 +408,10 @@ selection out of `wf_core`.
|
||||
records the parent `subgraph` trace entry.
|
||||
- Child output maps to parent state through existing output binding machinery.
|
||||
- The parent step routes through the child's terminal workflow outcome.
|
||||
- Child interrupts reject explicitly until structural resume routing exists.
|
||||
- Child interrupts route structurally and preserve the blocked parent boundary
|
||||
until the child resumes and completes.
|
||||
|
||||
### Slice 2: Interrupt Bubbling and Resume
|
||||
### Completed Slice 2: Interrupt Bubbling and Resume
|
||||
|
||||
- Extend `InterruptRequest` with explicit route structure.
|
||||
- Bubble child interrupts to the parent run.
|
||||
|
||||
@@ -131,29 +131,31 @@ limits and intended adapter seam.
|
||||
strategies beyond exact-path mergeable reducers.
|
||||
- Interrupt lifecycle is still node-level and run-state-level. Long-lived
|
||||
external subscriptions or notification streams need a separate lifecycle
|
||||
design. Interrupt `request` and `resume` are canonical binding lists; nested
|
||||
child-workflow resume is still future work.
|
||||
design. Interrupt `request` and `resume` are canonical binding lists;
|
||||
prepared child workflows retain a typed internal interrupt route so resume
|
||||
continues in child scope while exposing the parent subgraph boundary to the
|
||||
caller.
|
||||
- Native subgraphs use `SubgraphNode` plus caller-supplied `PreparedSubgraph`
|
||||
dependencies. A prepared local child executes through a child runtime scope
|
||||
and lineage; child output commits only through declared boundary bindings and
|
||||
the parent routes by the child's terminal workflow outcome. Saved/deployed
|
||||
workflow resolution remains outside core and is not implemented at this
|
||||
boundary yet.
|
||||
- Nested subgraph interruption is not first-class yet. The current
|
||||
`wf_authoring` subgraph helpers wrap a child workflow as an ordinary sync or
|
||||
async node and validate the child output; they do not preserve a child run
|
||||
state that can interrupt, bubble to the parent, and later resume inside the
|
||||
child. Native `SubgraphNode` preserves prepared-child execution and trace,
|
||||
but rejects child interrupts until route-aware resume is implemented. See
|
||||
`examples/authoring_workflow_as_node.py` for the wrapper-node shape.
|
||||
- Saved workflow-as-node execution with interrupts requires a core runtime
|
||||
upgrade: nested run state, child-frame trace preservation, interrupt bubbling
|
||||
with path metadata, and resume back into the child workflow.
|
||||
- The current `wf_authoring` wrapper helpers still run child workflows as
|
||||
ordinary sync or async nodes and therefore do not preserve native child
|
||||
state or resumable interrupts. Native `SubgraphNode` plus
|
||||
`PreparedSubgraph` is the first-class path: prepared child interrupts now
|
||||
bubble to the parent run and resume inside the original child scope. See
|
||||
`examples/authoring_workflow_as_node.py` for the compatibility wrapper shape
|
||||
and `examples/authoring_native_subgraph.py` for the native path.
|
||||
- Saved workflow-as-node execution with interrupts still requires platform
|
||||
resolution of artifact/deployment references into prepared child
|
||||
dependencies before core execution begins.
|
||||
- Frames are no longer only a serial execution stack: the runtime has a ready
|
||||
queue, `BLOCKED` frame state, lineage isolation, barrier merge semantics, and
|
||||
pending child results for concurrent foreach. Native prepared subgraphs now
|
||||
use child-scope execution; saved/deployed child resolution and nested
|
||||
interruption remain outstanding.
|
||||
use child-scope execution and typed routed child interruption; saved/deployed
|
||||
child resolution remains outstanding.
|
||||
Concurrent foreach is the primary current use case for async concurrent node
|
||||
handler execution.
|
||||
- Runtime errors are still ordinary exceptions plus failed run status. A richer
|
||||
|
||||
+14
-12
@@ -648,20 +648,22 @@ document, `WorkflowCapabilityRef` is the public callable capability name, and
|
||||
The first implementation should prefer artifact validation and dependency
|
||||
diagnostics before attempting persistent nested resume.
|
||||
|
||||
Native subgraphs now have a core `SubgraphNode` placeholder. It validates the
|
||||
Native subgraphs now have a core `SubgraphNode` boundary. It validates the
|
||||
parent-side contract: child workflow reference, declared child input/output
|
||||
schemas, binding lists, and declared outcomes. Runtime execution is still not
|
||||
implemented; reaching a subgraph step raises a clear runtime error. The current
|
||||
`wf_authoring.subgraph_node` and `async_subgraph_node` helpers still execute a
|
||||
child workflow as a plain node and validate the child output. The async helper
|
||||
is explicit because hiding `asyncio.run()` inside the sync wrapper would break
|
||||
inside already-running event loops. Future saved-workflow-as-node execution
|
||||
needs a real child run state if child interrupts should pause the parent and
|
||||
later resume the child.
|
||||
schemas, binding lists, and declared outcomes. When callers resolve a local
|
||||
child into `PreparedSubgraph`, core executes it in child scope, preserves its
|
||||
trace, and can bubble and resume child interrupts without exposing child state
|
||||
as parent state. The current `wf_authoring.subgraph_node` and
|
||||
`async_subgraph_node` helpers still execute a child workflow as a plain node
|
||||
and validate the child output. The async helper is explicit because hiding
|
||||
`asyncio.run()` inside the sync wrapper would break inside already-running
|
||||
event loops. Saved workflow-as-node execution still needs platform-level
|
||||
artifact/deployment resolution into prepared children before core can run it.
|
||||
|
||||
See `examples/authoring_workflow_as_node.py` for the current wrapper-node
|
||||
approach. In that example the parent trace sees one node call; the child
|
||||
workflow's internal trace is not embedded in the parent run state.
|
||||
See `examples/authoring_workflow_as_node.py` for the compatibility wrapper-node
|
||||
approach and `examples/authoring_native_subgraph.py` for native prepared-child
|
||||
execution. In the wrapper example the parent trace sees one node call; in the
|
||||
native example child trace entries remain in the parent run state.
|
||||
|
||||
Until that core upgrade exists, artifact tooling must not assume that an
|
||||
interrupting saved workflow can safely be used as a child node. Top-level saved
|
||||
|
||||
@@ -37,6 +37,7 @@ from .runtime import (
|
||||
from .run_state import (
|
||||
ExecutionFrame,
|
||||
FrameStatus,
|
||||
InterruptRoute,
|
||||
InterruptRequest,
|
||||
RunState,
|
||||
RunStatus,
|
||||
@@ -81,6 +82,7 @@ __all__ = [
|
||||
"RuntimeContext",
|
||||
"StepExecutionResult",
|
||||
"TraceEntry",
|
||||
"InterruptRoute",
|
||||
"InterruptRequest",
|
||||
"START",
|
||||
"END",
|
||||
|
||||
@@ -161,8 +161,8 @@ class SubgraphNode(BaseModel):
|
||||
"""Workflow boundary step for native prepared-child execution.
|
||||
|
||||
The runtime can execute an already-prepared local child graph through a
|
||||
child scope/lineage and commit only its mapped boundary output. Resolving
|
||||
saved artifacts and resuming child interrupts remain platform/runtime work.
|
||||
child scope/lineage, bubble and resume child interrupts, and commit only
|
||||
its mapped boundary output. Resolving saved artifacts remains platform work.
|
||||
"""
|
||||
|
||||
id: str
|
||||
|
||||
@@ -119,6 +119,23 @@ class StepExecutionResult:
|
||||
state_changes: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InterruptRoute:
|
||||
"""Internal resume route for an interrupt raised below a graph boundary.
|
||||
|
||||
`InterruptRequest.frame_id` and `.node_id` may describe the public parent
|
||||
subgraph boundary. This route retains the actual interrupted child frame so
|
||||
resume can continue inside its original workflow scope.
|
||||
"""
|
||||
|
||||
frame_id: str
|
||||
node_id: str
|
||||
scope_id: str
|
||||
lineage_id: str
|
||||
parent_frame_id: str
|
||||
workflow_ref: WorkflowRef
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InterruptRequest:
|
||||
id: str
|
||||
@@ -127,6 +144,7 @@ class InterruptRequest:
|
||||
kind: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
resumable: bool = True
|
||||
route: InterruptRoute | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -81,12 +81,17 @@ def resume_workflow(
|
||||
subgraphs: Mapping[str, PreparedSubgraph[NodeHandler]] | None = None,
|
||||
) -> RunState:
|
||||
"""Resume a synchronous run from its current state."""
|
||||
interrupted_workflow, interrupted_reducers = _interrupt_resume_target(
|
||||
workflow, reducers, run, subgraphs, resuming=resume_payload is not None
|
||||
)
|
||||
index = prepare_resume(
|
||||
workflow,
|
||||
run,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
reducers=reducers,
|
||||
interrupted_workflow=interrupted_workflow,
|
||||
interrupted_reducers=interrupted_reducers,
|
||||
)
|
||||
if index is None:
|
||||
if run.current_node_id == END:
|
||||
@@ -128,12 +133,17 @@ async def resume_workflow_async(
|
||||
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
|
||||
) -> RunState:
|
||||
"""Resume an async run from its current state."""
|
||||
interrupted_workflow, interrupted_reducers = _interrupt_resume_target(
|
||||
workflow, reducers, run, subgraphs, resuming=resume_payload is not None
|
||||
)
|
||||
index = prepare_resume(
|
||||
workflow,
|
||||
run,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
reducers=reducers,
|
||||
interrupted_workflow=interrupted_workflow,
|
||||
interrupted_reducers=interrupted_reducers,
|
||||
)
|
||||
if index is None:
|
||||
if run.current_node_id == END:
|
||||
@@ -164,6 +174,21 @@ async def resume_workflow_async(
|
||||
return finalize_run(workflow, run)
|
||||
|
||||
|
||||
def _interrupt_resume_target(
|
||||
root_workflow: Workflow,
|
||||
root_reducers: Mapping[str, ReducerDefinition] | None,
|
||||
run: RunState,
|
||||
subgraphs: Mapping[str, PreparedSubgraph[Any]] | None,
|
||||
*,
|
||||
resuming: bool,
|
||||
) -> tuple[Workflow | None, Mapping[str, ReducerDefinition] | None]:
|
||||
"""Resolve the workflow that owns an outstanding routed child interrupt."""
|
||||
if not resuming or run.interrupt is None or run.interrupt.route is None:
|
||||
return None, root_reducers
|
||||
child = resolve_prepared_subgraph(run.interrupt.route.workflow_ref, subgraphs)
|
||||
return child.workflow, child.reducers
|
||||
|
||||
|
||||
def _sync_execution_target(
|
||||
root_workflow: Workflow,
|
||||
root_registry: Mapping[str, NodeHandler],
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.conditions import eval_condition
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.models.steps import ConditionNode, InterruptNode
|
||||
from wf_core.run_state import FrameStatus, RunState, RunStatus, StepExecutionResult
|
||||
from wf_core.run_state import (
|
||||
ROOT_SCOPE_ID,
|
||||
ExecutionFrame,
|
||||
FrameStatus,
|
||||
InterruptRoute,
|
||||
RunState,
|
||||
RunStatus,
|
||||
StepExecutionResult,
|
||||
)
|
||||
from wf_core.runtime.lineage import scope_input_for_frame
|
||||
from wf_core.runtime.ops.flow import append_trace
|
||||
from wf_core.runtime.ops.frames import frame_context_values
|
||||
@@ -44,12 +53,32 @@ def handle_interrupt_step(
|
||||
step: InterruptNode,
|
||||
) -> RunState:
|
||||
frame = run.current_frame()
|
||||
public_frame = frame
|
||||
route = None
|
||||
if frame.scope_id != ROOT_SCOPE_ID:
|
||||
public_frame = _owning_subgraph_frame(run, frame)
|
||||
scope = run.scopes.get(frame.scope_id)
|
||||
if scope is None or scope.workflow_ref is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"child interrupt frame {frame.id!r} has no workflow scope"
|
||||
)
|
||||
route = InterruptRoute(
|
||||
frame_id=frame.id,
|
||||
node_id=frame.node_id,
|
||||
scope_id=frame.scope_id,
|
||||
lineage_id=frame.lineage_id,
|
||||
parent_frame_id=public_frame.id,
|
||||
workflow_ref=scope.workflow_ref,
|
||||
)
|
||||
interrupt_request = build_interrupt_request(
|
||||
step,
|
||||
frame_id=frame.id,
|
||||
state=run.state,
|
||||
workflow_input=run.workflow_input,
|
||||
state=state_view_for_frame(run, frame),
|
||||
workflow_input=scope_input_for_frame(run, frame),
|
||||
context=frame_context_values(frame),
|
||||
public_frame_id=public_frame.id,
|
||||
public_node_id=public_frame.node_id,
|
||||
route=route,
|
||||
)
|
||||
run.interrupt = interrupt_request
|
||||
run.status = RunStatus.INTERRUPTED
|
||||
@@ -66,3 +95,21 @@ def handle_interrupt_step(
|
||||
state_changes={},
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
def _owning_subgraph_frame(run: RunState, frame: ExecutionFrame) -> ExecutionFrame:
|
||||
"""Return the graph-boundary frame that owns one child-scope interrupt."""
|
||||
cursor = frame
|
||||
while cursor.parent_frame_id is not None:
|
||||
parent = run.frames.get(cursor.parent_frame_id)
|
||||
if parent is None:
|
||||
raise WorkflowExecutionError(
|
||||
f"child interrupt frame {frame.id!r} references missing parent "
|
||||
f"{cursor.parent_frame_id!r}"
|
||||
)
|
||||
if parent.scope_id != frame.scope_id:
|
||||
return parent
|
||||
cursor = parent
|
||||
raise WorkflowExecutionError(
|
||||
f"child interrupt frame {frame.id!r} has no parent subgraph"
|
||||
)
|
||||
|
||||
@@ -8,11 +8,19 @@ from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.local_paths import LocalPathError, set_local_value
|
||||
from wf_core.models.steps import InputPathBinding, InputValueBinding, InterruptNode
|
||||
from wf_core.models.workflow import Workflow
|
||||
from wf_core.run_state import InterruptRequest, RunState, StepExecutionResult
|
||||
from wf_core.run_state import (
|
||||
FrameStatus,
|
||||
InterruptRequest,
|
||||
InterruptRoute,
|
||||
RunState,
|
||||
StepExecutionResult,
|
||||
)
|
||||
from wf_core.runtime.lineage import commit_patch_for_frame
|
||||
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
|
||||
from wf_core.runtime.ops.index import WorkflowIndex
|
||||
from wf_core.runtime.ops.merges import ReducerDefinition
|
||||
from wf_core.runtime.ops.state import apply_output_bindings
|
||||
from wf_core.runtime.ops.overlays import state_view_for_frame
|
||||
from wf_core.runtime.ops.state import build_output_patch
|
||||
|
||||
|
||||
def build_interrupt_request(
|
||||
@@ -22,6 +30,9 @@ def build_interrupt_request(
|
||||
state: dict[str, Any],
|
||||
workflow_input: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
public_frame_id: str | None = None,
|
||||
public_node_id: str | None = None,
|
||||
route: InterruptRoute | None = None,
|
||||
) -> InterruptRequest:
|
||||
payload: dict[str, Any] = {}
|
||||
for binding in node.request:
|
||||
@@ -43,11 +54,12 @@ def build_interrupt_request(
|
||||
except LocalPathError as exc:
|
||||
raise WorkflowExecutionError(str(exc)) from exc
|
||||
return InterruptRequest(
|
||||
id=f"interrupt:{node.id}",
|
||||
frame_id=frame_id,
|
||||
node_id=node.id,
|
||||
id=f"interrupt:{public_node_id or node.id}",
|
||||
frame_id=public_frame_id or frame_id,
|
||||
node_id=public_node_id or node.id,
|
||||
kind=node.kind,
|
||||
payload=payload,
|
||||
route=route,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,14 +72,28 @@ def resume_interrupt(
|
||||
resume_outcome: str,
|
||||
reducers: Mapping[str, ReducerDefinition] | None = None,
|
||||
) -> None:
|
||||
if run.current_frame_id is None:
|
||||
raise WorkflowExecutionError("interrupted run has no current frame")
|
||||
if run.current_node_id is None:
|
||||
raise WorkflowExecutionError("interrupted run has no current node")
|
||||
if run.interrupt is None:
|
||||
raise WorkflowExecutionError("run is interrupted but has no interrupt request")
|
||||
|
||||
frame = run.current_frame()
|
||||
route = run.interrupt.route
|
||||
if route is not None:
|
||||
frame = run.frames.get(route.frame_id)
|
||||
if (
|
||||
frame is None
|
||||
or frame.scope_id != route.scope_id
|
||||
or frame.lineage_id != route.lineage_id
|
||||
or frame.node_id != route.node_id
|
||||
or frame.status != FrameStatus.INTERRUPTED
|
||||
):
|
||||
raise WorkflowExecutionError("child interrupt route is no longer resumable")
|
||||
run.current_frame_id = frame.id
|
||||
run.sync_from_current_frame()
|
||||
else:
|
||||
if run.current_frame_id is None:
|
||||
raise WorkflowExecutionError("interrupted run has no current frame")
|
||||
if run.current_node_id is None:
|
||||
raise WorkflowExecutionError("interrupted run has no current node")
|
||||
frame = run.current_frame()
|
||||
step = index.nodes_by_id[frame.node_id]
|
||||
if not isinstance(step, InterruptNode):
|
||||
raise WorkflowExecutionError(
|
||||
@@ -78,14 +104,15 @@ def resume_interrupt(
|
||||
f"interrupt node {step.id!r} does not declare resume outcome {resume_outcome!r}"
|
||||
)
|
||||
|
||||
state_changes = apply_output_bindings(
|
||||
patch = build_output_patch(
|
||||
workflow,
|
||||
step.resume,
|
||||
resume_payload,
|
||||
run.state,
|
||||
state_view_for_frame(run, frame),
|
||||
reducers=reducers,
|
||||
missing_field_message="interrupt resume payload is missing required field {field}",
|
||||
)
|
||||
state_changes = commit_patch_for_frame(run, frame, patch)
|
||||
next_node_id = index.next_node_id(frame.node_id, resume_outcome)
|
||||
append_step_result_trace(
|
||||
run,
|
||||
|
||||
@@ -34,6 +34,8 @@ def prepare_resume(
|
||||
resume_payload: dict[str, Any] | None,
|
||||
resume_outcome: str,
|
||||
reducers: Mapping[str, ReducerDefinition] | None = None,
|
||||
interrupted_workflow: Workflow | None = None,
|
||||
interrupted_reducers: Mapping[str, ReducerDefinition] | None = None,
|
||||
) -> WorkflowIndex | None:
|
||||
"""Validate and normalize a run state before resume execution."""
|
||||
if run.workflow_name != workflow.name:
|
||||
@@ -55,13 +57,21 @@ def prepare_resume(
|
||||
if run.status == RunStatus.INTERRUPTED:
|
||||
if resume_payload is None:
|
||||
return None
|
||||
resume_workflow = interrupted_workflow or workflow
|
||||
resume_index = (
|
||||
index
|
||||
if resume_workflow is workflow
|
||||
else build_workflow_index(resume_workflow)
|
||||
)
|
||||
resume_interrupt(
|
||||
workflow,
|
||||
resume_workflow,
|
||||
run,
|
||||
index=index,
|
||||
index=resume_index,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
reducers=reducers,
|
||||
reducers=(
|
||||
reducers if interrupted_workflow is None else interrupted_reducers
|
||||
),
|
||||
)
|
||||
if run.current_frame_id is not None:
|
||||
frame = run.current_frame()
|
||||
|
||||
@@ -40,7 +40,6 @@ from wf_core.runtime.scheduler import (
|
||||
)
|
||||
from wf_core.runtime.subgraphs import PreparedSubgraph, step_subgraph
|
||||
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult
|
||||
from wf_core.run_state import ROOT_SCOPE_ID
|
||||
from wf_core.tokens import END
|
||||
|
||||
from .preparation import prepare_step
|
||||
@@ -155,10 +154,6 @@ def step_workflow(
|
||||
outcome=step.outcome,
|
||||
)
|
||||
elif isinstance(step, InterruptNode):
|
||||
if frame.kind == "subgraph_root" or frame.scope_id != ROOT_SCOPE_ID:
|
||||
raise WorkflowExecutionError(
|
||||
"child interrupts are not supported until native subgraph resume routing exists"
|
||||
)
|
||||
return handle_interrupt_step(run, step)
|
||||
elif isinstance(step, ForeachNode):
|
||||
return step_foreach(workflow, run, step, index, reducers=reducers)
|
||||
@@ -271,10 +266,6 @@ async def step_workflow_async(
|
||||
outcome=step.outcome,
|
||||
)
|
||||
elif isinstance(step, InterruptNode):
|
||||
if frame.kind == "subgraph_root" or frame.scope_id != ROOT_SCOPE_ID:
|
||||
raise WorkflowExecutionError(
|
||||
"child interrupts are not supported until native subgraph resume routing exists"
|
||||
)
|
||||
return handle_interrupt_step(run, step)
|
||||
elif isinstance(step, ForeachNode):
|
||||
return step_foreach(workflow, run, step, index, reducers=reducers)
|
||||
|
||||
@@ -21,6 +21,8 @@ from wf_core import (
|
||||
WorkflowExecutionError,
|
||||
execute_workflow_async,
|
||||
execute_workflow,
|
||||
resume_workflow_async,
|
||||
resume_workflow,
|
||||
)
|
||||
from wf_core.validation.issues import ValidationIssueCode
|
||||
from wf_core.models.steps import Step
|
||||
@@ -194,28 +196,70 @@ def test_subgraph_step_routes_through_child_terminal_outcome() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_subgraph_step_rejects_child_interrupt_until_resume_route_exists() -> None:
|
||||
child = Workflow(
|
||||
name="child.workflow",
|
||||
input_schema=_schema({"text": {"type": "string"}}),
|
||||
state_schema=StateSchema.from_field_map({}),
|
||||
output_schema=_schema({}),
|
||||
start="ask",
|
||||
nodes=[
|
||||
InterruptNode.model_validate(
|
||||
{"id": "ask", "type": "interrupt", "kind": "input"}
|
||||
)
|
||||
],
|
||||
edges=[Edge.model_validate({"from": "ask", "outcome": "submitted", "to": END})],
|
||||
def test_subgraph_step_interrupts_and_resumes_inside_prepared_child() -> None:
|
||||
child = _interrupting_child_workflow()
|
||||
parent = _workflow(
|
||||
node=_subgraph_node(input_bindings=[{"target": "text", "value": "child-only"}]),
|
||||
output_schema=_schema({"answer": {"type": "string"}}),
|
||||
)
|
||||
prepared = PreparedSubgraph(workflow=child, registry={})
|
||||
|
||||
run = execute_workflow(
|
||||
parent,
|
||||
{"text": "hello"},
|
||||
{},
|
||||
subgraphs={"child.workflow": prepared},
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="child interrupts"):
|
||||
execute_workflow(
|
||||
_workflow(),
|
||||
assert run.status == "interrupted"
|
||||
assert run.interrupt is not None
|
||||
assert run.interrupt.node_id == "child"
|
||||
assert run.interrupt.payload["question"] == "child-only"
|
||||
assert run.interrupt.route is not None
|
||||
assert run.interrupt.route.node_id == "ask"
|
||||
assert run.frames["root"].status == "blocked"
|
||||
|
||||
paused = resume_workflow(parent, run, {})
|
||||
|
||||
assert paused.status == "interrupted"
|
||||
assert paused.interrupt is not None
|
||||
|
||||
resumed = resume_workflow(
|
||||
parent,
|
||||
run,
|
||||
{},
|
||||
resume_payload={"answer": "yes"},
|
||||
subgraphs={"child.workflow": prepared},
|
||||
)
|
||||
|
||||
assert resumed.status == "completed"
|
||||
assert resumed.output["answer"] == "yes"
|
||||
assert resumed.state["answer"] == "yes"
|
||||
|
||||
|
||||
def test_subgraph_step_resumes_interrupted_async_prepared_child() -> None:
|
||||
async def run_child() -> RunState:
|
||||
child = _interrupting_child_workflow()
|
||||
parent = _workflow(output_schema=_schema({"answer": {"type": "string"}}))
|
||||
prepared = PreparedSubgraph(workflow=child, registry={})
|
||||
run = await execute_workflow_async(
|
||||
parent,
|
||||
{"text": "hello"},
|
||||
{},
|
||||
subgraphs={"child.workflow": PreparedSubgraph(workflow=child, registry={})},
|
||||
subgraphs={"child.workflow": prepared},
|
||||
)
|
||||
return await resume_workflow_async(
|
||||
parent,
|
||||
run,
|
||||
{},
|
||||
resume_payload={"answer": "async yes"},
|
||||
subgraphs={"child.workflow": prepared},
|
||||
)
|
||||
|
||||
resumed = asyncio.run(run_child())
|
||||
|
||||
assert resumed.status == "completed"
|
||||
assert resumed.output["answer"] == "async yes"
|
||||
|
||||
|
||||
def _workflow(
|
||||
@@ -299,5 +343,27 @@ def _child_workflow(
|
||||
)
|
||||
|
||||
|
||||
def _interrupting_child_workflow() -> Workflow:
|
||||
return Workflow(
|
||||
name="child.workflow",
|
||||
input_schema=_schema({"text": {"type": "string"}}),
|
||||
state_schema=StateSchema.from_field_map({"answer": StateField(type="string")}),
|
||||
output_schema=_schema({"answer": {"type": "string"}}),
|
||||
start="ask",
|
||||
nodes=[
|
||||
InterruptNode.model_validate(
|
||||
{
|
||||
"id": "ask",
|
||||
"type": "interrupt",
|
||||
"kind": "input",
|
||||
"request": [{"target": "question", "path": "input.text"}],
|
||||
"resume": [{"source": "answer", "target": "state.answer"}],
|
||||
}
|
||||
)
|
||||
],
|
||||
edges=[Edge.model_validate({"from": "ask", "outcome": "submitted", "to": END})],
|
||||
)
|
||||
|
||||
|
||||
def _schema(properties: dict[str, object]) -> SchemaRef:
|
||||
return SchemaRef.model_validate({"type": "object", "properties": properties})
|
||||
|
||||
Reference in New Issue
Block a user