36 KiB
Self-Describing Interrupt Contracts 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: Add machine-readable interrupt request/resume schemas to workflow definitions, persisted interrupt inspection, and resume validation.
Architecture: Store JSON Schema dictionaries on InterruptNode, validate request/resume payloads with the existing runtime JSON Schema helper, and copy the contract into InterruptRequest so durable run inspection is self-contained. Keep legacy workflows compatible by defaulting missing schemas to permissive object schemas and marking them typed=false.
Tech Stack: Python 3.14, Pydantic, jsonschema, pytest, Typer CLI, JSON-RPC HTTP transport.
Scope
Implement the prerequisite platform contract from
docs/superpowers/specs/2026-07-01-self-describing-interrupt-contracts.md.
Do not build the web console, the demo agent, or the prepared lda.chat report
workflow in this plan. This slice only makes interrupts self-describing.
File Structure
- Modify
src/wf_core/models/steps.py- Add
request_schema,resume_schema, andtyped-derivation helpers toInterruptNode.
- Add
- Modify
src/wf_core/run_state.py- Add interrupt contract fields to
InterruptRequest.
- Add interrupt contract fields to
- Modify
src/wf_core/runtime/ops/interrupts.py- Validate request payloads before pausing.
- Validate resume payloads before state mutation.
- Copy schemas/outcomes/typed flag into
InterruptRequest.
- Modify
src/wf_core/validation/steps.py- Validate interrupt schema objects during workflow validation.
- Modify
src/wf_api/runs.py- Ensure
inspect_run,run start,trace, andresumeinclude JSON-safe interrupt contract fields.
- Ensure
- Modify
src/wf_cli/commands/runs.py- Keep JSON output as source of truth.
- Improve
resumedocstring/help to mention schema validation.
- Modify docs and skills:
docs/wf_cli.mddocs/current_roadmap.mdskills/wf-cli/SKILL.mdskills/wf-workflow/references/workflow-lifecycle.md
- Tests:
tests/core/test_canonical_node_bindings.pytests/core/test_execution_results.pytests/core/test_run_codec.pytests/core/test_subgraph_step.pytests/wf_api/test_run_api.pytests/wf_cli/test_run_deploy.pytests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py
Behavioral Contract
Use this permissive schema for legacy/untyped interrupts:
def _object_schema() -> dict[str, object]:
return {"type": "object", "additionalProperties": True}
An interrupt is typed when the original node payload explicitly includes either
request_schema or resume_schema.
Runtime validation rules:
- request payload validation happens after request bindings build
payloadand before settingrun.interrupt; - resume payload validation happens after resolving the paused
InterruptNodeand beforebuild_output_patch; - validation uses
wf_core.runtime.ops.schemas.validate_payload_against_schema; - invalid payloads raise
WorkflowExecutionErrorthrough the existing runtime error path; - no state mutation or trace append happens when resume payload validation fails.
Task 1: Model Defaults And Serialization
Files:
-
Modify:
src/wf_core/models/steps.py -
Test:
tests/core/test_canonical_node_bindings.py -
Step 1: Add failing tests for interrupt schemas
Add these tests near the existing interrupt binding tests in
tests/core/test_canonical_node_bindings.py:
def test_interrupt_node_defaults_to_untyped_object_contract():
node = InterruptNode.model_validate(
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
}
)
assert node.request_schema == {
"type": "object",
"additionalProperties": True,
}
assert node.resume_schema == {
"type": "object",
"additionalProperties": True,
}
assert node.has_explicit_contract is False
dumped = node.model_dump(mode="json")
assert dumped["request_schema"] == {
"type": "object",
"additionalProperties": True,
}
assert dumped["resume_schema"] == {
"type": "object",
"additionalProperties": True,
}
def test_interrupt_node_accepts_explicit_request_and_resume_schemas():
node = InterruptNode.model_validate(
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
"request_schema": {
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
"additionalProperties": False,
},
"resume_schema": {
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
"additionalProperties": False,
},
}
)
assert node.has_explicit_contract is True
assert node.request_schema["required"] == ["message"]
assert node.resume_schema["required"] == ["approved"]
- Step 2: Run tests and verify red
Run:
uv run pytest tests/core/test_canonical_node_bindings.py::test_interrupt_node_defaults_to_untyped_object_contract tests/core/test_canonical_node_bindings.py::test_interrupt_node_accepts_explicit_request_and_resume_schemas -q -n0
Expected: both fail because InterruptNode has no schema fields.
- Step 3: Add model fields and explicit-contract detection
In src/wf_core/models/steps.py, add this helper near InterruptNode:
def _object_schema() -> dict[str, object]:
"""Default legacy interrupt contract: any JSON object payload is accepted."""
return {"type": "object", "additionalProperties": True}
Update InterruptNode:
class InterruptNode(BaseModel):
"""Control-flow step that pauses a run and waits for resume input."""
id: str
type: Literal["interrupt"]
kind: str
request: list[InputBinding] = Field(
default_factory=list,
description=(
"Bindings that build the interrupt request payload sent to the client."
),
)
resume: list[OutputBinding] = Field(
default_factory=list,
description=(
"Bindings that commit resume payload fields back into workflow state."
),
)
outcomes: list[str] = Field(default_factory=lambda: ["submitted"])
request_schema: dict[str, object] = Field(default_factory=_object_schema)
resume_schema: dict[str, object] = Field(default_factory=_object_schema)
has_explicit_contract: bool = Field(default=False, exclude=True)
In _coerce_deprecated_maps, before returning normalized, add:
normalized["has_explicit_contract"] = (
"request_schema" in normalized or "resume_schema" in normalized
)
This preserves explicitness before defaults are applied.
- Step 4: Run model tests and verify green
Run:
uv run pytest tests/core/test_canonical_node_bindings.py::test_interrupt_node_defaults_to_untyped_object_contract tests/core/test_canonical_node_bindings.py::test_interrupt_node_accepts_explicit_request_and_resume_schemas -q -n0
Expected: both pass.
- Step 5: Commit
git add src/wf_core/models/steps.py tests/core/test_canonical_node_bindings.py
git commit -m "feat: add interrupt schema contract fields"
Task 2: Validate Interrupt Schemas Statically
Files:
-
Modify:
src/wf_core/validation/steps.py -
Test:
tests/core/test_canonical_node_bindings.py -
Step 1: Add failing test for invalid schema rejection
Add this test to tests/core/test_canonical_node_bindings.py:
def test_interrupt_node_rejects_invalid_json_schema_contract():
with pytest.raises(ValidationError, match="invalid JSON Schema"):
InterruptNode.model_validate(
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
"resume_schema": {
"type": "object",
"properties": {"approved": {"type": "not-a-json-schema-type"}},
},
}
)
- Step 2: Run test and verify red
Run:
uv run pytest tests/core/test_canonical_node_bindings.py::test_interrupt_node_rejects_invalid_json_schema_contract -q -n0
Expected: fail because plain dict schema fields are not checked.
- Step 3: Add schema field validators
In src/wf_core/models/steps.py, add imports:
from jsonschema import Draft202012Validator, SchemaError, validators
from pydantic import field_validator
Extend the existing Pydantic import instead of creating a second import.
Add this helper near _object_schema:
def _validate_json_schema(value: object, *, field_name: str) -> dict[str, object]:
"""Validate one interrupt contract schema with jsonschema."""
if not isinstance(value, Mapping):
raise ValueError(f"{field_name} must be a JSON Schema object")
schema = dict(value)
validator_cls = (
validators.validator_for(schema)
if "$schema" in schema
else Draft202012Validator
)
try:
validator_cls.check_schema(schema)
except SchemaError as exc:
raise ValueError(f"invalid JSON Schema: {exc.message}") from exc
schema_type = schema.get("type")
if schema_type != "object":
raise ValueError(f"{field_name} must describe a JSON object")
return schema
Add validators inside InterruptNode:
@field_validator("request_schema", "resume_schema", mode="before")
@classmethod
def _validate_interrupt_schema(cls, value: object, info: object) -> object:
field_name = getattr(info, "field_name", "interrupt schema")
return _validate_json_schema(value, field_name=field_name)
- Step 4: Run test and verify green
Run:
uv run pytest tests/core/test_canonical_node_bindings.py::test_interrupt_node_rejects_invalid_json_schema_contract tests/core/test_canonical_node_bindings.py::test_interrupt_node_accepts_explicit_request_and_resume_schemas -q -n0
Expected: pass.
- Step 5: Commit
git add src/wf_core/models/steps.py tests/core/test_canonical_node_bindings.py
git commit -m "fix: validate interrupt schema contracts"
Task 3: Persist Interrupt Contract In Run State
Files:
-
Modify:
src/wf_core/run_state.py -
Modify:
src/wf_core/runtime/ops/interrupts.py -
Test:
tests/core/test_run_codec.py -
Step 1: Add failing codec test
Add this test to tests/core/test_run_codec.py:
def test_run_state_codec_round_trips_interrupt_contract_fields() -> None:
run = RunState(
workflow_name="approval",
status=RunStatus.INTERRUPTED,
workflow_input={},
state={},
interrupt=InterruptRequest(
id="interrupt:approval",
frame_id="frame_1",
node_id="approval",
kind="approval",
payload={"message": "approve?"},
outcomes=["submitted", "cancelled"],
request_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
resume_schema={
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
},
typed=True,
),
)
restored = decode_run_state(encode_run_state(run))
assert restored.interrupt is not None
assert restored.interrupt.outcomes == ["submitted", "cancelled"]
assert restored.interrupt.request_schema["required"] == ["message"]
assert restored.interrupt.resume_schema["required"] == ["approved"]
assert restored.interrupt.typed is True
If tests/core/test_run_codec.py uses differently named codec helpers, use the
existing helpers in that file and keep the assertions unchanged.
- Step 2: Run test and verify red
Run:
uv run pytest tests/core/test_run_codec.py::test_run_state_codec_round_trips_interrupt_contract_fields -q -n0
Expected: fail because InterruptRequest lacks the new fields.
- Step 3: Extend
InterruptRequest
In src/wf_core/run_state.py, add:
def _object_schema() -> dict[str, object]:
"""Default legacy interrupt contract used when older checkpoints are loaded."""
return {"type": "object", "additionalProperties": True}
Update InterruptRequest:
@dataclass(slots=True)
class InterruptRequest:
id: str
frame_id: str
node_id: str
kind: str
payload: dict[str, Any] = field(default_factory=dict)
resumable: bool = True
route: InterruptRoute | None = None
outcomes: list[str] = field(default_factory=lambda: ["submitted"])
request_schema: dict[str, object] = field(default_factory=_object_schema)
resume_schema: dict[str, object] = field(default_factory=_object_schema)
typed: bool = False
- Step 4: Copy contract fields when building interrupt requests
In src/wf_core/runtime/ops/interrupts.py, update the InterruptRequest(...)
construction:
return InterruptRequest(
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,
outcomes=list(node.outcomes),
request_schema=dict(node.request_schema),
resume_schema=dict(node.resume_schema),
typed=node.has_explicit_contract,
)
- Step 5: Run codec and existing interrupt tests
Run:
uv run pytest tests/core/test_run_codec.py tests/core/test_execution_results.py -q -n0
Expected: pass.
- Step 6: Commit
git add src/wf_core/run_state.py src/wf_core/runtime/ops/interrupts.py tests/core/test_run_codec.py
git commit -m "feat: persist interrupt contract in run state"
Task 4: Runtime Request Payload Validation
Files:
-
Modify:
src/wf_core/runtime/ops/interrupts.py -
Test:
tests/core/test_execution_results.py -
Step 1: Add failing request-schema runtime test
Add this test to tests/core/test_execution_results.py:
async def test_interrupt_request_payload_validates_against_schema() -> None:
workflow = Workflow(
name="bad_interrupt_request",
input_schema={"type": "object", "properties": {}},
state_schema={"fields": {}},
output_schema={"type": "object", "properties": {}},
outcomes=["failed"],
start="ask",
nodes=[
InterruptNode(
id="ask",
type="interrupt",
kind="approval",
request=[input_value_binding("not a number", "count")],
request_schema={
"type": "object",
"properties": {"count": {"type": "number"}},
"required": ["count"],
"additionalProperties": False,
},
)
],
edges=[],
)
run = await execute_workflow_async(workflow, {}, {})
assert run.status == RunStatus.FAILED
assert run.interrupt is None
assert run.error is not None
assert "interrupt request for ask['count']" in run.error
Use existing imports/helpers in the file. If input_value_binding is not
available, import it from wf_core.models.steps or construct
InputValueBinding(target="count", value="not a number").
- Step 2: Run test and verify red
Run:
uv run pytest tests/core/test_execution_results.py::test_interrupt_request_payload_validates_against_schema -q -n0
Expected: fail because request payload is not validated.
- Step 3: Validate request payload before returning
InterruptRequest
In src/wf_core/runtime/ops/interrupts.py, import:
from wf_core.runtime.ops.schemas import validate_payload_against_schema
After request bindings build payload and before return InterruptRequest(...),
add:
validate_payload_against_schema(
node.request_schema,
payload,
f"interrupt request for {node.id}",
)
- Step 4: Run request-validation tests
Run:
uv run pytest tests/core/test_execution_results.py::test_interrupt_request_payload_validates_against_schema tests/authoring/test_demo_workflow.py::test_interrupt_then_resume_to_send_email -q -n0
Expected: pass.
- Step 5: Commit
git add src/wf_core/runtime/ops/interrupts.py tests/core/test_execution_results.py
git commit -m "fix: validate interrupt request payloads"
Task 5: Runtime Resume Payload Validation
Files:
-
Modify:
src/wf_core/runtime/ops/interrupts.py -
Test:
tests/core/test_execution_results.py -
Step 1: Add failing resume-schema runtime test
Add this test to tests/core/test_execution_results.py:
async def test_interrupt_resume_payload_validates_before_state_mutation() -> None:
workflow = Workflow(
name="resume_validation",
input_schema={"type": "object", "properties": {}},
state_schema={
"type": "object",
"properties": {
"approved": {"type": "boolean", "reducer": "wf.std.replace"}
},
},
output_schema={"type": "object", "properties": {}},
outcomes=["submitted"],
start="ask",
nodes=[
InterruptNode(
id="ask",
type="interrupt",
kind="approval",
resume=[output_binding("approved", "state.approved")],
resume_schema={
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
"additionalProperties": False,
},
),
EndNode(id="end", type="end", outcome="submitted"),
],
edges=[Edge.model_validate({"from": "ask", "outcome": "submitted", "to": "end"})],
)
interrupted = await execute_workflow_async(workflow, {}, {})
resumed = await resume_workflow_async(
workflow,
interrupted,
{"approved": "yes"},
{},
resume_outcome="submitted",
)
assert resumed.status == RunStatus.FAILED
assert resumed.state == {}
assert resumed.error is not None
assert "interrupt resume for ask['approved']" in resumed.error
Use the existing edge/binding helper style in the file. If EndNode or
output_binding is not imported, import them from existing core modules used by
the file.
- Step 2: Run test and verify red
Run:
uv run pytest tests/core/test_execution_results.py::test_interrupt_resume_payload_validates_before_state_mutation -q -n0
Expected: fail because resume payload is not validated before bindings.
- Step 3: Validate resume payload before
build_output_patch
In resume_interrupt() in src/wf_core/runtime/ops/interrupts.py, after the
resume_outcome not in step.outcomes check and before patch = build_output_patch(...),
add:
validate_payload_against_schema(
step.resume_schema,
resume_payload,
f"interrupt resume for {step.id}",
)
- Step 4: Run resume tests
Run:
uv run pytest tests/core/test_execution_results.py::test_interrupt_resume_payload_validates_before_state_mutation tests/authoring/test_demo_workflow.py::test_interrupt_then_resume_to_send_email tests/core/test_concurrent_foreach_interrupts.py -q -n0
Expected: pass.
- Step 5: Commit
git add src/wf_core/runtime/ops/interrupts.py tests/core/test_execution_results.py
git commit -m "fix: validate interrupt resume payloads"
Task 6: Expose Contract Through Run Inspection
Files:
-
Modify:
src/wf_api/runs.py -
Test:
tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py -
Test:
tests/wf_cli/test_run_deploy.py -
Step 1: Add RPC inspection assertion
In tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py, update
_interrupt_plan() so its interrupt node includes explicit schemas:
"request_schema": {
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
"additionalProperties": False,
},
"resume_schema": {
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
"additionalProperties": False,
},
In test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild, after
assert started["interrupt"]["payload"]["message"] == "approve after restart?",
add:
assert started["interrupt"]["outcomes"] == ["submitted"]
assert started["interrupt"]["typed"] is True
assert started["interrupt"]["request_schema"]["required"] == ["message"]
assert started["interrupt"]["resume_schema"]["required"] == ["approved"]
- Step 2: Add CLI inspection assertion
In tests/wf_cli/test_run_deploy.py, update _interrupt_artifact() interrupt
node with the same request_schema and a permissive but explicit
resume_schema:
"request_schema": {
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
"additionalProperties": False,
},
"resume_schema": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
In test_wf_run_watch_stops_on_interrupted_run, after the interrupt payload
assertions, add:
assert payload["interrupt"]["typed"] is True
assert payload["interrupt"]["request_schema"]["required"] == ["message"]
- Step 3: Run tests and verify red if payload missing fields
Run:
uv run pytest tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py::test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild tests/wf_cli/test_run_deploy.py::test_wf_run_watch_stops_on_interrupted_run -q -n0
Expected: fail only if inspect payload omits the new contract fields. If it
already passes because dataclass asdict includes fields, continue to Step 4
and keep the tests as regression coverage.
- Step 4: Keep
_interrupt_payloadJSON-safe and explicit
In src/wf_api/runs.py, keep _interrupt_payload() as the single public shape
normalizer. Ensure it preserves:
payload = asdict(run.interrupt)
If the route-normalization block mutates route only, leave schema fields
untouched. Add this comment above payload = asdict(run.interrupt):
# The interrupt contract is copied into RunState at pause time so clients
# can render/resume without reloading mutable workflow definitions.
- Step 5: Run RPC/CLI tests
Run:
uv run pytest tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py::test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild tests/wf_cli/test_run_deploy.py::test_wf_run_watch_stops_on_interrupted_run -q -n0
Expected: pass.
- Step 6: Commit
git add src/wf_api/runs.py tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py tests/wf_cli/test_run_deploy.py
git commit -m "feat: expose interrupt contracts in run inspect"
Task 7: API/CLI Resume Validation Regression
Files:
-
Test:
tests/wf_api/test_run_api.py -
Test:
tests/wf_cli/test_run_deploy.py -
Modify:
src/wf_cli/commands/runs.py -
Step 1: Add API regression test for invalid resume payload
Add this test to tests/wf_api/test_run_api.py:
async def test_resume_run_rejects_payload_that_violates_interrupt_schema(tmp_path: Path) -> None:
store = FileStore(tmp_path / "store")
api = build_workflow_api_for_test(store)
deployment_id = _seed_interrupt_deployment_with_resume_schema(store)
started = await api.run_deployment(
deployment_id=deployment_id,
workflow_input={"message": "approve?"},
)
run_id = started["run_id"]
assert run_id is not None
resumed = await api.resume_run(
run_id=run_id,
resume_payload={"approved": "yes"},
resume_outcome="submitted",
)
assert resumed["status"] == "failed"
assert "interrupt resume for approval['approved']" in resumed["error"]
Use existing helper names in tests/wf_api/test_run_api.py. If the file does
not have build_workflow_api_for_test or FileStore, mirror the existing
store/API setup used by nearby run tests. Add a local helper:
def _seed_interrupt_deployment_with_resume_schema(store: FileStore) -> str:
"""Seed one deployment whose interrupt requires a boolean approval."""
artifact_store = FileWorkflowArtifactStore(store.root)
deployment_store = FileWorkflowDeploymentStore(store.root)
artifact_store.save_artifact(_interrupt_artifact_with_resume_schema())
deployment_store.save_deployment(
WorkflowDeployment(
id="approval.default",
artifact_id="approval",
version=1,
source_bindings={},
)
)
return "approval.default"
Reuse existing artifact/deployment classes imported in that test file.
- Step 2: Add CLI help assertion
In tests/wf_cli/test_app.py, add:
def test_wf_run_resume_help_mentions_interrupt_schema_validation() -> None:
result = runner.invoke(app, ["run", "resume", "--help"])
assert result.exit_code == 0
assert "resume payload" in result.stdout
assert "schema" in result.stdout
- Step 3: Run tests and verify red if help missing schema wording
Run:
uv run pytest tests/wf_cli/test_app.py::test_wf_run_resume_help_mentions_interrupt_schema_validation -q -n0
Expected: fail until help/docstring mentions schema validation.
- Step 4: Update CLI help/docstring
In src/wf_cli/commands/runs.py, change the --payload help text:
typer.Option(
"--payload",
help="Resume payload JSON object; interrupted runs may validate it against their resume schema.",
)
Change --payload-file help text:
typer.Option(
"--payload-file",
help="Path to resume payload JSON object; schema validation happens before state mutation.",
)
Extend the resume_run docstring:
The interrupted run may expose a resume schema through `wf run inspect`.
Invalid resume payloads are rejected before workflow state is mutated.
- Step 5: Run focused tests
Run:
uv run pytest tests/wf_cli/test_app.py::test_wf_run_resume_help_mentions_interrupt_schema_validation tests/wf_api/test_run_api.py -q -n0
Expected: pass.
- Step 6: Commit
git add src/wf_cli/commands/runs.py tests/wf_cli/test_app.py tests/wf_api/test_run_api.py
git commit -m "test: cover interrupt resume schema validation"
Task 8: Authoring Builder Convenience
Files:
-
Modify:
src/wf_authoring/dsl/builder.py -
Test:
tests/authoring/test_builder.py -
Step 1: Inspect current builder interrupt signature
Run:
rg -n 'def interrupt|InterruptNode' src\wf_authoring tests\authoring\test_builder.py
Expected: find the builder method that constructs InterruptNode.
- Step 2: Add failing builder test
Add this test to tests/authoring/test_builder.py near the existing interrupt
builder tests:
def test_builder_interrupt_accepts_request_and_resume_schemas() -> None:
builder = WorkflowBuilder(
name="interrupt_contract",
input_schema={"type": "object", "properties": {}},
state_schema={"fields": {}},
output_schema={"type": "object", "properties": {}},
)
interrupt = builder.interrupt(
kind="approval",
request_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
},
resume_schema={
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
},
)
assert interrupt.request_schema["required"] == ["message"]
assert interrupt.resume_schema["required"] == ["approved"]
assert interrupt.has_explicit_contract is True
- Step 3: Run test and verify red
Run:
uv run pytest tests/authoring/test_builder.py::test_builder_interrupt_accepts_request_and_resume_schemas -q -n0
Expected: fail because builder does not accept schema kwargs.
- Step 4: Add builder kwargs
In src/wf_authoring/dsl/builder.py, update the interrupt(...) method
signature to accept:
request_schema: Mapping[str, Any] | None = None,
resume_schema: Mapping[str, Any] | None = None,
When constructing InterruptNode, pass:
request_schema=dict(request_schema) if request_schema is not None else None,
resume_schema=dict(resume_schema) if resume_schema is not None else None,
If passing None would serialize null into the model payload, build the payload
dict and include only non-None schema keys:
payload: dict[str, Any] = {
"id": node_id,
"type": "interrupt",
"kind": kind,
"request": list(request or []),
"resume": list(resume or []),
"outcomes": list(outcomes or ["submitted"]),
}
if request_schema is not None:
payload["request_schema"] = dict(request_schema)
if resume_schema is not None:
payload["resume_schema"] = dict(resume_schema)
node = InterruptNode.model_validate(payload)
Use existing variable names from the method.
- Step 5: Run builder tests
Run:
uv run pytest tests/authoring/test_builder.py::test_builder_interrupt_accepts_request_and_resume_schemas tests/authoring/test_builder.py::test_builder_interrupt_accepts_canonical_request_and_resume_bindings -q -n0
Expected: pass.
- Step 6: Commit
git add src/wf_authoring/dsl/builder.py tests/authoring/test_builder.py
git commit -m "feat: add interrupt schema builder helpers"
Task 9: Schema Discovery And Docs
Files:
-
Modify:
docs/wf_cli.md -
Modify:
skills/wf-cli/SKILL.md -
Modify:
skills/wf-workflow/references/workflow-lifecycle.md -
Modify:
docs/current_roadmap.md -
Optional test:
tests/wf_cli/test_schema.py -
Step 1: Verify
wf schema InterruptNodeincludes fields
Run:
uv run wf schema InterruptNode --verbose
Expected: JSON Schema output includes request_schema and resume_schema.
If it does not, inspect src/wf_cli/schema_catalog.py and add InterruptNode
field projection coverage there. Add or update a test in
tests/wf_cli/test_schema.py:
def test_wf_schema_interrupt_node_includes_interrupt_contract_fields() -> None:
result = runner.invoke(app, ["schema", "InterruptNode", "--verbose"])
assert result.exit_code == 0
assert "request_schema" in result.stdout
assert "resume_schema" in result.stdout
- Step 2: Update CLI docs
In docs/wf_cli.md, add a short subsection under run inspect/resume docs:
### Interrupt Resume Schemas
Interrupted runs may include `interrupt.request_schema` and
`interrupt.resume_schema` in `wf run inspect` output. The request schema
describes the payload shown to the operator. The resume schema describes the
payload accepted by `wf run resume --payload` or `--payload-file`.
Resume payload validation happens before workflow state mutation. If validation
fails, inspect the schema and retry with a payload that matches the declared
shape.
- Step 3: Update CLI skill
In skills/wf-cli/SKILL.md, add:
- For interrupted runs, call `wf run inspect <run_id>` before resuming. If the
interrupt includes `resume_schema`, shape `wf run resume --payload` to that
schema instead of guessing field names.
- Step 4: Update workflow lifecycle skill reference
In skills/wf-workflow/references/workflow-lifecycle.md, add:
## Interrupt Resume Contracts
An interrupted run can carry a self-describing resume contract. Treat
`interrupt.resume_schema` from `wf run inspect` as the source of truth for the
payload you pass to `wf run resume`. Do not read workflow source code just to
guess approval fields when the schema is present.
- Step 5: Update roadmap
In docs/current_roadmap.md, under the active Workflow Console initiative,
change implementation item 1 from future wording to completed wording only if
the full implementation is done:
1. Completed: self-describing interrupt request/resume schemas are carried
through core execution, persisted run inspection, and resume validation.
Do not mark the web console itself completed.
- Step 6: Run docs/schema checks
Run:
uv run pytest tests/wf_cli/test_schema.py tests/docs -q -n0
Expected: pass.
- Step 7: Commit
git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/workflow-lifecycle.md docs/current_roadmap.md tests/wf_cli/test_schema.py src/wf_cli/schema_catalog.py
git commit -m "docs: document interrupt resume contracts"
If src/wf_cli/schema_catalog.py and tests/wf_cli/test_schema.py were not
needed, omit them from git add.
Task 10: Final Regression
Files:
-
No new files expected.
-
Step 1: Run focused interrupt suites
Run:
uv run pytest tests/core/test_canonical_node_bindings.py tests/core/test_execution_results.py tests/core/test_run_codec.py tests/authoring/test_builder.py tests/wf_api/test_run_api.py tests/wf_cli/test_run_deploy.py tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py -q -n0
Expected: pass.
- Step 2: Run lint and type checks
Run:
uv run ruff check
uv run ruff format --check
uv run basedpyright --level error
Expected: all clean. If ruff format --check fails, run:
uv run ruff format
uv run ruff format --check
- Step 3: Run a CLI smoke with an interrupting deployment
Run an existing CLI interrupt test as product smoke:
uv run pytest tests/wf_cli/test_run_deploy.py::test_wf_run_resume_interrupted_run -q -n0
Expected: pass.
- Step 4: Search for stale docs
Run:
rg -n 'request_schema|resume_schema|InterruptNode|wf run resume|interrupt schema' docs skills src tests
Expected: new docs mention interrupt schemas; no docs claim resume payload shape must be guessed from source code.
- Step 5: Commit final polish if needed
If formatting/docs search caused changes:
git add .
git commit -m "chore: polish interrupt contract implementation"
If no changes are needed, do not create an empty commit.
Final Report Requirements
The implementing agent must report:
- files changed;
- test commands and results;
- whether legacy untyped interrupts still pass;
- whether invalid resume payloads fail before state mutation;
- whether
wf run inspectexposesrequest_schema,resume_schema,outcomes, andtyped; - any pre-existing failures outside this slice.
Plan Self-Review
Spec coverage:
- core model fields: Task 1;
- static schema validation: Task 2;
- persisted interrupt payload contract: Task 3 and Task 6;
- request validation: Task 4;
- resume validation before mutation: Task 5 and Task 7;
- builder convenience: Task 8;
- CLI/schema/docs/skills: Task 9;
- compatibility/defaults: Task 1 and Task 3;
- no web console or demo-agent implementation: scoped out explicitly.
Placeholder scan:
- No task contains red-flag placeholder tokens or unspecified implementation work.
- Each code-changing task has concrete code or exact behavior.
Type consistency:
- Public fields are consistently named
request_schema,resume_schema,outcomes, andtyped. - Legacy marker is internal-only as
has_explicit_contractand is excluded from serialized workflow documents.