35 KiB
Durable Workflow Runs and 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: Persist stopped workflow executions so interrupted deployments can resume after process restart, while completed and failed runs become inspectable history.
Architecture: Add a versioned storage codec around the existing wf_core.RunState dataclass using Pydantic TypeAdapter, rather than duplicating runtime models. Add typed run/checkpoint records and a file-backed store under wf_artifacts.runs: wf_platform cannot own these models yet because wf_artifacts already depends on it for capability/source refs, and run records must strongly type pinned artifact/deployment snapshots. Replace the workflow surface's in-memory active-run dictionary with durable retrieval and pin the resolved saved-child artifact tree used by each run.
Tech Stack: Python 3.14, Pydantic v2 TypeAdapter, existing wf_core runtime dataclasses, existing wf_artifacts Pydantic models, file-backed JSON storage, FastMCP workflow tools, pytest, basedpyright, Ruff.
Scope And Decisions
- V1 persists one checkpoint whenever
run_deploymentorresume_runstops asinterrupted,completed, orfailed. - V1 does not persist mid-tool-call or per-node checkpoints.
unrunnableremains a pre-start response and does not create a run record.- Only an explicit
InterruptNodeproduces resumable execution. - A live MCP/tool transport failure persists a failed run; it is not converted into an interrupt.
- Resumed runs use pinned deployment/artifact/saved-child snapshots captured at start, then revalidate the external capabilities those pinned definitions require.
- A paused run with missing/disabled/incompatible dependencies remains
interruptedwith blocked resume readiness and diagnostics. - Existing
retry,timeout_seconds, andretry_countdeclarations remain unsupported runtime policy.
Target File Layout
src/wf_core/
run_codec.py # versioned RunState JSON storage boundary
src/wf_artifacts/runs/
__init__.py
models.py # WorkflowRunRecord, RunCheckpoint, pinned environment
store.py # RunStore protocol and FileRunStore
src/wf_mcp/workflow_surface/
run_lifecycle.py # workflow-surface persistence/resume helpers
handlers.py # thin delegation and payload assembly
tools.py # inspect_run/read_run_trace MCP exposure
saved_subgraphs.py # reconstruct prepared tree from pinned snapshots
src/wf_mcp/broker/
config.py # configure FileRunStore
service/core.py # accept pinned saved-child tree during execution/resume
tests/core/
test_run_codec.py
tests/artifacts/
test_run_store.py
tests/wf_mcp/
test_durable_runs.py
test_saved_subgraphs.py
test_server.py
handlers.py is already large. Add lifecycle-specific logic to
run_lifecycle.py; retain public methods on WorkflowSurfaceHandlers only as
thin MCP-facing delegation points.
Task 1: Add A Versioned RunState Storage Codec
Files:
-
Create:
src/wf_core/run_codec.py -
Modify:
src/wf_core/__init__.py -
Create:
tests/core/test_run_codec.py -
Step 1: Write failing round-trip tests for terminal and interrupted child-scope state
Create tests that prove the codec preserves typed values rather than returning loose dictionaries:
from wf_core import RunState, RunStatus, dump_run_state, load_run_state
from wf_core.models.reducers import ReducerRef
from wf_core.models.workflow_refs import WorkflowRef
from wf_core.paths import StatePath
from wf_core.run_state import (
InterruptRequest,
InterruptRoute,
LineageState,
RuntimeScope,
StateWrite,
)
def test_run_state_codec_round_trips_completed_output() -> None:
run = RunState(
workflow_name="echo",
status=RunStatus.COMPLETED,
workflow_input={"text": "hi"},
state={"echoed": "hi"},
outcome="ok",
output={"echoed": "hi"},
)
stored = dump_run_state(run)
restored = load_run_state(stored)
assert stored["version"] == 1
assert restored.status is RunStatus.COMPLETED
assert restored.output["echoed"] == "hi"
def test_run_state_codec_round_trips_child_interrupt_lineage_types() -> None:
run = RunState(
workflow_name="parent",
status=RunStatus.INTERRUPTED,
workflow_input={},
state={},
)
run.scopes["child"] = RuntimeScope(
id="child",
workflow_name="child",
workflow_ref=WorkflowRef(name="child"),
)
run.lineages["child-lineage"] = LineageState(
id="child-lineage",
scope_id="child",
writes=[
StateWrite(
path=StatePath(("count",)),
incoming_value=1,
visible_value=2,
reducer=ReducerRef.model_validate("wf.std.add"),
)
],
)
run.interrupt = InterruptRequest(
id="interrupt:child",
frame_id="parent-step",
node_id="child_step",
kind="approval",
route=InterruptRoute(
frame_id="child-frame",
node_id="ask",
scope_id="child",
lineage_id="child-lineage",
parent_frame_id="parent-step",
workflow_ref=WorkflowRef(name="child"),
),
)
restored = load_run_state(dump_run_state(run))
assert isinstance(restored.lineages["child-lineage"].writes[0].path, StatePath)
assert str(restored.lineages["child-lineage"].writes[0].reducer.ref) == "wf.std.add"
assert isinstance(restored.interrupt.route.workflow_ref, WorkflowRef)
- Step 2: Run tests and verify codec symbols are missing
Run:
uv run pytest -q tests/core/test_run_codec.py
Expected: FAIL because dump_run_state / load_run_state do not exist.
- Step 3: Implement the versioned envelope using
TypeAdapter(RunState)
Create src/wf_core/run_codec.py:
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from .run_state import RunState
class PersistedRunState(BaseModel):
"""Versioned JSON storage envelope for a stopped runtime snapshot."""
model_config = ConfigDict(extra="forbid")
version: Literal[1] = 1
state: dict[str, Any]
_RUN_STATE_ADAPTER = TypeAdapter(RunState)
def dump_run_state(run: RunState) -> dict[str, object]:
"""Serialize one stopped `RunState` into the durable v1 envelope."""
return PersistedRunState(
state=_RUN_STATE_ADAPTER.dump_python(run, mode="json")
).model_dump(mode="json")
def load_run_state(payload: object) -> RunState:
"""Validate and restore a durable v1 runtime snapshot."""
envelope = PersistedRunState.model_validate(payload)
try:
return _RUN_STATE_ADAPTER.validate_python(envelope.state)
except ValidationError as exc:
raise ValueError("invalid persisted workflow run state") from exc
Export PersistedRunState, dump_run_state, and load_run_state from
src/wf_core/__init__.py.
This intentionally uses the runtime dataclasses as the single execution model. Do not create duplicate Pydantic copies of every frame/scope/lineage type.
- Step 4: Run codec tests and static checks
Run:
uv run pytest -q tests/core/test_run_codec.py
uvx ruff check src/wf_core/run_codec.py src/wf_core/__init__.py tests/core/test_run_codec.py
uv run basedpyright --level error src/wf_core tests/core/test_run_codec.py
Expected: PASS and 0 errors.
Task 2: Add Typed Run Records And A File-Backed Run Store
Files:
-
Create:
src/wf_artifacts/runs/__init__.py -
Create:
src/wf_artifacts/runs/models.py -
Create:
src/wf_artifacts/runs/store.py -
Modify:
src/wf_artifacts/__init__.py -
Create:
tests/artifacts/test_run_store.py -
Step 1: Write failing tests for run/checkpoint persistence
Create tests using existing artifact/deployment helper style from
tests/artifacts/test_store.py:
def test_file_run_store_round_trips_pinned_environment_and_checkpoint() -> None:
store = FileRunStore(local_temp_root() / "run_store")
environment = PinnedRunEnvironment(
deployment=_deployment(),
root_artifact=_artifact(),
child_artifacts=[_child_artifact()],
)
now = datetime.now(UTC)
run = WorkflowRunRecord(
id="run_123",
environment=environment,
status=StoredRunStatus.INTERRUPTED,
resume_readiness=ResumeReadiness.READY,
latest_checkpoint_id="run_123.000001",
created_at=now,
updated_at=now,
)
checkpoint = RunCheckpoint(
id="run_123.000001",
run_id=run.id,
sequence=1,
reason=CheckpointReason.INTERRUPTED,
state=dump_run_state(_interrupted_run_state()),
)
store.save_run(run)
store.save_checkpoint(checkpoint)
restored_run = store.get_run("run_123")
restored_checkpoint = store.get_latest_checkpoint("run_123")
assert restored_run.environment.root_artifact.id == "parent"
assert restored_run.environment.child_artifacts[0].id == "child"
assert restored_checkpoint.sequence == 1
def test_file_run_store_lists_runs_and_reads_bounded_checkpoints() -> None:
store = FileRunStore(local_temp_root() / "run_listing")
first = _run_record("run_a", latest_checkpoint_id="run_a.000002")
second = _run_record("run_b", latest_checkpoint_id="run_b.000001")
store.save_run(second)
store.save_run(first)
store.save_checkpoint(_checkpoint("run_a", sequence=1))
store.save_checkpoint(_checkpoint("run_a", sequence=2))
assert [record.id for record in store.list_runs()] == ["run_a", "run_b"]
assert [item.sequence for item in store.list_checkpoints("run_a")] == [1, 2]
- Step 2: Run tests and verify the run package is absent
Run:
uv run pytest -q tests/artifacts/test_run_store.py
Expected: FAIL on missing wf_artifacts.runs.
- Step 3: Implement typed run models
Create src/wf_artifacts/runs/models.py:
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from wf_core import PersistedRunState
from ..models import DependencyDiagnostic, WorkflowArtifact, WorkflowDeployment
class StoredRunStatus(StrEnum):
INTERRUPTED = "interrupted"
COMPLETED = "completed"
FAILED = "failed"
class ResumeReadiness(StrEnum):
READY = "ready"
BLOCKED = "blocked"
NOT_APPLICABLE = "not_applicable"
class CheckpointReason(StrEnum):
INTERRUPTED = "interrupted"
COMPLETED = "completed"
FAILED = "failed"
class PinnedRunEnvironment(BaseModel):
"""Immutable execution definitions captured when a run starts."""
model_config = ConfigDict(extra="forbid")
deployment: WorkflowDeployment
root_artifact: WorkflowArtifact
child_artifacts: list[WorkflowArtifact] = Field(default_factory=list)
class WorkflowRunRecord(BaseModel):
"""Durable summary and pinned environment for one started run."""
model_config = ConfigDict(extra="forbid")
id: str
status: StoredRunStatus
resume_readiness: ResumeReadiness
environment: PinnedRunEnvironment
latest_checkpoint_id: str
diagnostics: list[DependencyDiagnostic] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class RunCheckpoint(BaseModel):
"""One stopped-state checkpoint written at a public run boundary."""
model_config = ConfigDict(extra="forbid")
id: str
run_id: str
sequence: int = Field(ge=1)
reason: CheckpointReason
state: PersistedRunState
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
Use a small factory function or classmethod for timestamp initialization only if later lifecycle implementation demonstrates repeated construction boilerplate. Do not invent mutable state transitions inside the Pydantic models.
- Step 4: Implement
RunStoreandFileRunStore
Create src/wf_artifacts/runs/store.py with:
class RunStore:
"""Persistence boundary for execution run summaries and checkpoints."""
def save_run(self, run: WorkflowRunRecord) -> None:
raise NotImplementedError
def get_run(self, run_id: str) -> WorkflowRunRecord:
raise NotImplementedError
def list_runs(self) -> list[WorkflowRunRecord]:
raise NotImplementedError
def save_checkpoint(self, checkpoint: RunCheckpoint) -> None:
raise NotImplementedError
def get_latest_checkpoint(self, run_id: str) -> RunCheckpoint:
raise NotImplementedError
def list_checkpoints(self, run_id: str) -> list[RunCheckpoint]:
raise NotImplementedError
class FileRunStore(RunStore):
"""JSON file-backed stopped-run store for local execution."""
def __init__(self, root: Path) -> None:
self.root = root
self.runs_dir.mkdir(parents=True, exist_ok=True)
@property
def runs_dir(self) -> Path:
return self.root / "runs"
Store each run at runs/<safe_run_id>/run.json and each checkpoint at
runs/<safe_run_id>/checkpoints/<sequence:06d>.json. Reuse or add the same
safe identifier validation discipline used for draft workspaces; reject path
separators and traversal before writing.
- Step 5: Export the package and verify store tests
Export the run models/store from src/wf_artifacts/runs/__init__.py and
src/wf_artifacts/__init__.py.
Run:
uv run pytest -q tests/artifacts/test_run_store.py
uvx ruff check src/wf_artifacts tests/artifacts/test_run_store.py
uv run basedpyright --level error src/wf_artifacts tests/artifacts/test_run_store.py
Expected: PASS and 0 errors.
Task 3: Pin The Resolved Saved-Child Environment Used By A Run
Files:
-
Modify:
src/wf_mcp/workflow_surface/saved_subgraphs.py -
Modify:
src/wf_mcp/broker/service/core.py -
Test:
tests/wf_mcp/test_saved_subgraphs.py -
Test:
tests/wf_mcp/test_durable_runs.py -
Step 1: Write failing tests proving resume uses saved snapshots rather than overwritten artifact files
Add a durable run integration test:
def test_interrupted_run_resumes_against_pinned_child_snapshot_after_store_overwrite() -> None:
store = FileWorkflowArtifactStore(local_temp_root() / "pinned_children")
run_store = FileRunStore(local_temp_root() / "pinned_children")
store.save_artifact(_parent_artifact())
store.save_artifact(_interrupting_child_artifact(output_field="echoed"))
store.save_deployment(_deployment())
first_handlers = _handlers(store, run_store=run_store)
paused = asyncio.run(
first_handlers.run_deployment(
deployment_id="parent.personal",
workflow_input={"text": "hello"},
)
)
# The current file store permits replacement of one artifact version.
store.save_artifact(_interrupting_child_artifact(output_field="changed"))
second_handlers = _handlers(store, run_store=run_store)
resumed = asyncio.run(
second_handlers.resume_run(
run_id=paused["run_id"],
resume_payload={"answer": "world"},
)
)
assert resumed["status"] == "completed"
assert resumed["output"]["echoed"] == "world"
This test is deliberately about pinning; it must not be changed to expect the overwritten child definition.
- Step 2: Run the new test and verify current process-local storage cannot pass it
Run:
uv run pytest -q tests/wf_mcp/test_durable_runs.py::test_interrupted_run_resumes_against_pinned_child_snapshot_after_store_overwrite
Expected: FAIL because the run is not durable and/or resume reloads child definitions from the artifact store.
- Step 3: Add reconstruction from pinned child artifact snapshots
In src/wf_mcp/workflow_surface/saved_subgraphs.py, add a constructor that
does not read the mutable artifact store:
def saved_subgraph_tree_from_snapshots(
child_artifacts: list[WorkflowArtifact],
) -> SavedSubgraphTree:
"""Restore a previously resolved child tree from pinned run snapshots."""
return SavedSubgraphTree(
artifacts_by_ref={
str(workflow_ref_from_artifact(artifact)): artifact
for artifact in child_artifacts
},
diagnostics=[],
)
Use the actual existing reference display helper if its spelling differs; do not create a new dotted-string parser.
- Step 4: Let service execution accept an already-pinned tree
Extend _prepare_workflow_runtime, run_workflow_from_plan, and
resume_workflow_from_plan with an optional saved_subgraph_tree parameter.
When supplied, compile/prepare from it rather than resolving descendants again
through artifact_store.
Keep the comment explicit:
# Durable resumes must execute the saved child definitions captured when the
# run started; the file artifact store can otherwise be overwritten in place.
- Step 5: Verify saved-child and pinning tests
Run:
uv run pytest -q tests/wf_mcp/test_saved_subgraphs.py tests/wf_mcp/test_durable_runs.py
uv run basedpyright --level error src/wf_mcp tests/wf_mcp/test_saved_subgraphs.py tests/wf_mcp/test_durable_runs.py
Expected: PASS and 0 errors.
Task 4: Replace Process-Local Active Runs With Durable Lifecycle Helpers
Files:
-
Modify:
src/wf_core/runtime/engine.py -
Create:
src/wf_mcp/workflow_surface/run_lifecycle.py -
Modify:
src/wf_mcp/workflow_surface/handlers.py -
Modify:
src/wf_mcp/broker/service/core.py -
Modify:
src/wf_mcp/broker/config.py -
Test:
tests/wf_mcp/test_durable_runs.py -
Create:
tests/core/test_execution_results.py -
Step 1: Write failing tests for completed, failed, and restart-resumable run persistence
Create tests/wf_mcp/test_durable_runs.py with cases:
def test_completed_deployment_creates_durable_run_and_checkpoint() -> None:
handlers, run_store = _completed_handlers_with_run_store()
payload = asyncio.run(
handlers.run_deployment(
deployment_id="echo.personal",
workflow_input={"text": "hello"},
)
)
stored = run_store.get_run(payload["run_id"])
checkpoint = run_store.get_latest_checkpoint(payload["run_id"])
assert stored.status == StoredRunStatus.COMPLETED
assert stored.resume_readiness == ResumeReadiness.NOT_APPLICABLE
assert load_run_state(checkpoint.state.model_dump()).output["echoed"] == "hello"
def test_interrupted_run_resumes_after_handler_recreation() -> None:
first_handlers, run_store = _interrupting_handlers_with_run_store()
paused = asyncio.run(
first_handlers.run_deployment(
deployment_id="parent.personal",
workflow_input={"text": "hello"},
)
)
second_handlers, _ = _interrupting_handlers_with_run_store(run_store=run_store)
resumed = asyncio.run(second_handlers.resume_run(
run_id=paused["run_id"],
resume_payload={"answer": "world"},
))
assert resumed["status"] == "completed"
assert run_store.get_latest_checkpoint(paused["run_id"]).sequence == 2
def test_runtime_failure_is_persisted_as_failed_not_interrupted() -> None:
handlers, run_store = _failing_handlers_with_run_store()
payload = asyncio.run(
handlers.run_deployment(
deployment_id="explode.personal",
workflow_input={},
)
)
assert payload["status"] == "failed"
assert run_store.get_run(payload["run_id"]).status == StoredRunStatus.FAILED
def test_failure_after_resume_is_persisted_as_failed_not_interrupted() -> None:
handlers, run_store = _interrupt_then_fail_handlers_with_run_store()
paused = asyncio.run(
handlers.run_deployment(
deployment_id="approval.personal",
workflow_input={},
)
)
failed = asyncio.run(
handlers.resume_run(
run_id=paused["run_id"],
resume_payload={"approved": True},
)
)
assert failed["status"] == "failed"
assert run_store.get_run(paused["run_id"]).status == StoredRunStatus.FAILED
Also add a core regression test for a new captured-result API:
def test_execute_workflow_result_async_returns_failed_state_without_changing_strict_execute() -> None:
workflow = Workflow(
name="failing",
input_schema=_schema({}),
state_schema=StateSchema.from_field_map({}),
output_schema=_schema({}),
outcomes=["ok"],
start="explode",
node_defs=[
NodeDef(
name="explode",
input_schema=_schema({}),
output_schema=_schema({}),
outcomes=["ok"],
)
],
nodes=[NodeUse(id="explode", type="node", node="explode")],
edges=[Edge.model_validate({"from": "explode", "outcome": "ok", "to": END})],
)
def explode(_payload: dict[str, object], _context: RuntimeContext) -> dict[str, object]:
raise ValueError("boom")
async def explode_async(
payload: dict[str, object], context: RuntimeContext
) -> dict[str, object]:
return explode(payload, context)
failed = asyncio.run(
execute_workflow_result_async(workflow, {}, {"explode": explode_async})
)
assert failed.status is RunStatus.FAILED
assert "boom" in failed.error
with pytest.raises(ValueError, match="boom"):
asyncio.run(execute_workflow_async(workflow, {}, {"explode": explode_async}))
def test_resume_workflow_result_async_returns_failed_state_without_changing_strict_resume() -> None:
interrupted = _interrupted_run_before_explode()
failed = asyncio.run(
resume_workflow_result_async(
_interrupt_then_explode_workflow(),
interrupted,
{"explode": _explode_async},
resume_payload={"approved": True},
)
)
assert failed.status is RunStatus.FAILED
assert "boom" in failed.error
This preserves the existing strict core execution API for direct callers while
giving the platform a failed RunState it can persist.
- Step 2: Run tests and verify process-local behavior fails restart resume
Run:
uv run pytest -q tests/wf_mcp/test_durable_runs.py
Expected: FAIL because no run store is wired and _active_runs is process-local.
- Step 3: Add a core result-returning execution entrypoint for the platform
In src/wf_core/runtime/engine.py, add a sibling entrypoint such as:
async def execute_workflow_result_async(
workflow: Workflow,
workflow_input: dict[str, Any],
registry: Mapping[str, AsyncNodeHandler],
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
) -> RunState:
"""Execute and return terminal failed state rather than raising node failures."""
run = create_run_state(workflow, workflow_input)
try:
prepare_new_run(workflow, workflow_input, run)
return await resume_workflow_async(
workflow,
run,
registry,
reducers=reducers,
subgraphs=subgraphs,
)
except Exception as exc:
run.status = RunStatus.FAILED
run.error = str(exc)
return run
async def resume_workflow_result_async(
workflow: Workflow,
run: RunState,
registry: Mapping[str, AsyncNodeHandler],
*,
resume_payload: dict[str, Any] | None = None,
resume_outcome: str = "submitted",
reducers: Mapping[str, ReducerDefinition] | None = None,
subgraphs: Mapping[str, PreparedSubgraph[AsyncNodeHandler]] | None = None,
) -> RunState:
"""Resume and return terminal failed state rather than raising node failures."""
try:
return await resume_workflow_async(
workflow,
run,
registry,
resume_payload=resume_payload,
resume_outcome=resume_outcome,
reducers=reducers,
subgraphs=subgraphs,
)
except Exception as exc:
run.status = RunStatus.FAILED
run.error = str(exc)
return run
Export it through wf_core.__init__. Use this new result-oriented entrypoint
from WfMcpService.run_workflow_from_plan, and use
resume_workflow_result_async from resume_workflow_from_plan; do not change
existing strict raise-on-failure APIs.
- Step 4: Add lifecycle helper functions outside
handlers.py
Create src/wf_mcp/workflow_surface/run_lifecycle.py with focused helpers:
def create_pinned_environment(
*,
deployment: WorkflowDeployment,
artifact: WorkflowArtifact,
tree: SavedSubgraphTree,
) -> PinnedRunEnvironment:
"""Capture the exact graph definitions and bindings used for one run."""
def persist_stopped_run(
*,
store: RunStore,
environment: PinnedRunEnvironment,
run: RunState,
run_id: str | None = None,
) -> WorkflowRunRecord:
"""Write one run summary and its next stopped-state checkpoint."""
def restore_interrupted_run(
store: RunStore, run_id: str
) -> tuple[WorkflowRunRecord, RunState]:
"""Load the latest typed checkpoint for a resumable interrupted run."""
persist_stopped_run must reject a RunState still marked PENDING or
RUNNING; V1 stores stopped boundaries only.
- Step 5: Add
run_storedependency toWfMcpService
In src/wf_mcp/broker/service/core.py, add:
run_store: RunStore | None = None
In __post_init__, default it to:
if self.run_store is None:
self.run_store = FileRunStore(_store_root(self.store))
In src/wf_mcp/broker/config.py, pass
run_store=FileRunStore(config.store_root) alongside existing stores.
- Step 6: Replace
_active_runsuse in the workflow surface
In handlers.py:
- remove
ActiveWorkflowRun - remove
self._active_runs - on successful start execution, call
persist_stopped_run(...) - on resume, load
WorkflowRunRecordplus checkpoint throughrestore_interrupted_run(...) - reconstruct the pinned saved-child tree and run from its stored environment
- persist the new stopped snapshot under the existing
run_id
Do not embed file-store knowledge in handlers. It receives only RunStore.
- Step 7: Verify durable lifecycle tests
Run:
uv run pytest -q tests/core/test_execution_results.py tests/wf_mcp/test_durable_runs.py tests/wf_mcp/test_saved_subgraphs.py tests/wf_mcp/test_workflow_surface.py
uvx ruff check src/wf_core src/wf_mcp src/wf_artifacts tests/wf_mcp/test_durable_runs.py
uv run basedpyright --level error src/wf_core src/wf_mcp src/wf_artifacts tests/wf_mcp
Expected: PASS and 0 errors.
Task 5: Block Resume When Pinned External Dependencies Are No Longer Ready
Files:
-
Modify:
src/wf_mcp/workflow_surface/run_lifecycle.py -
Modify:
src/wf_mcp/workflow_surface/handlers.py -
Test:
tests/wf_mcp/test_durable_runs.py -
Step 1: Write failing tests for blocked and restored resume readiness
def test_resume_keeps_interrupted_run_blocked_when_pinned_source_is_disabled() -> None:
handlers, run_store, service = _paused_run_fixture()
paused = asyncio.run(
handlers.run_deployment(
deployment_id="parent.personal",
workflow_input={"text": "hello"},
)
)
service.connections.get("demo.personal").enabled = False
blocked = asyncio.run(handlers.resume_run(
run_id=paused["run_id"],
resume_payload={"answer": "world"},
))
stored = run_store.get_run(paused["run_id"])
checkpoint = run_store.get_latest_checkpoint(paused["run_id"])
assert blocked["status"] == "interrupted"
assert blocked["resume_readiness"] == "blocked"
assert blocked["diagnostics"][0]["severity"] == "error"
assert stored.resume_readiness == ResumeReadiness.BLOCKED
assert checkpoint.sequence == 1
def test_blocked_interrupted_run_can_resume_after_pinned_source_returns() -> None:
handlers, _run_store, service = _paused_run_fixture()
paused = asyncio.run(
handlers.run_deployment(
deployment_id="parent.personal",
workflow_input={"text": "hello"},
)
)
service.connections.get("demo.personal").enabled = False
asyncio.run(
handlers.resume_run(
run_id=paused["run_id"],
resume_payload={"answer": "world"},
)
)
service.connections.get("demo.personal").enabled = True
resumed = asyncio.run(
handlers.resume_run(
run_id=paused["run_id"],
resume_payload={"answer": "world"},
)
)
assert resumed["status"] == "completed"
assert resumed["resume_readiness"] == "not_applicable"
- Step 2: Run tests and confirm current resume proceeds without durable readiness
Run:
uv run pytest -q tests/wf_mcp/test_durable_runs.py -k 'blocked or returns'
Expected: FAIL.
- Step 3: Revalidate the pinned environment before mutation
Add a lifecycle function:
def validate_pinned_resume_environment(
*,
record: WorkflowRunRecord,
sources: dict[str, CapabilitySource],
) -> list[DependencyDiagnostic]:
"""Validate the stored graph contract against current external sources."""
It must validate record.environment.root_artifact and each stored child
artifact against record.environment.deployment; it must not load replacement
artifact definitions from the current artifact store.
In resume_run, perform this before applying resume payload or executing core.
If blocking diagnostics exist:
-
update only run summary readiness/diagnostics
-
keep
status="interrupted" -
do not append a checkpoint, because execution state did not change
-
return a compact blocked response
-
Step 4: Verify blocked-resume behavior
Run:
uv run pytest -q tests/wf_mcp/test_durable_runs.py tests/wf_mcp/test_saved_subgraphs.py
uv run basedpyright --level error src/wf_mcp tests/wf_mcp/test_durable_runs.py
Expected: PASS and 0 errors.
Task 6: Expose Inspect And Bounded Trace Tools
Files:
-
Modify:
src/wf_mcp/workflow_surface/handlers.py -
Modify:
src/wf_mcp/workflow_surface/tools.py -
Modify:
src/wf_mcp/broker/artifact_tools.py -
Test:
tests/wf_mcp/test_server.py -
Test:
tests/wf_mcp/test_durable_runs.py -
Step 1: Write failing public-surface tests
Add tests asserting:
def test_inspect_run_returns_summary_without_trace_payload() -> None:
payload = asyncio.run(handlers.inspect_run(run_id=run_id))
assert payload["run_id"] == run_id
assert payload["status"] == "completed"
assert payload["trace_count"] == 1
assert "trace" not in payload
def test_read_run_trace_returns_bounded_slice_and_total_count() -> None:
payload = asyncio.run(handlers.read_run_trace(
run_id=run_id,
trace_range=TraceRange(start=1, limit=1),
))
assert payload["trace_start"] == 1
assert payload["trace_limit"] == 1
assert payload["trace_count"] == 3
assert len(payload["trace"]) == 1
assert payload["trace_truncated"] is True
In server schema tests, assert field-level descriptions state that trace retrieval is debug-oriented and bounded.
- Step 2: Run tests and verify tools do not exist
Run:
uv run pytest -q tests/wf_mcp/test_durable_runs.py tests/wf_mcp/test_server.py
Expected: FAIL for missing handler/tool registrations.
- Step 3: Implement compact inspection and trace retrieval
Add handler methods:
async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
"""Return durable run summary and latest stopped-state result, without trace detail."""
async def read_run_trace(
self, *, run_id: str, trace_range: TraceRange
) -> dict[str, Any]:
"""Return only the requested slice of a stored run trace."""
Register:
wf.workflow.inspect_run
wf.workflow.read_run_trace
The legacy/broker alias surface may expose parallel helper names only if it is still exercised by tests; do not reintroduce redundant raw call tools.
- Step 4: Verify MCP tool response/schema tests
Run:
uv run pytest -q tests/wf_mcp/test_durable_runs.py tests/wf_mcp/test_server.py tests/wf_mcp/test_broker_server.py
uvx ruff check src/wf_mcp tests/wf_mcp
uv run basedpyright --level error src/wf_mcp tests/wf_mcp
Expected: PASS and 0 errors.
Task 7: Documentation, Regression Verification, And Unsupported Policy Notes
Files:
-
Modify:
docs/current_roadmap.md -
Modify:
docs/workflow_artifacts.md -
Modify:
docs/wf_mcp_operator_manual.md -
Modify:
docs/wf_mcp_troubleshooting.md -
Modify:
docs/superpowers/specs/2026-05-26-durable-workflow-runs-and-resume-design.md -
Step 1: Update docs from planned to implemented language
When implementation passes, update documentation to state:
- run ids are durable for stopped deployments
- completed, failed, and interrupted runs are stored
inspect_runandread_run_traceare the detail surfaces- interrupted runs can be blocked on pinned dependency drift and later resumed
- runtime transport failures are failed runs, never implicit pauses
- retry and timeout fields still have no runtime enforcement
Do not claim per-step crash recovery, replay, tasks/progress, or retry support.
- Step 2: Add troubleshooting paths
Document:
run_deployment -> interrupted -> inspect_run -> resume_run
resume_run -> blocked -> repair/reenable exact pinned source -> resume_run
run_deployment -> failed due to tool disconnect -> inspect_run/read_run_trace
- Step 3: Run the affected suites
Run:
uv run pytest -q tests/core/test_run_codec.py tests/artifacts/test_run_store.py tests/wf_mcp/test_durable_runs.py tests/wf_mcp/test_saved_subgraphs.py tests/wf_mcp/test_workflow_surface.py tests/wf_mcp/test_server.py tests/wf_mcp/test_broker_server.py
uvx ruff check src tests
uv run basedpyright --level error src tests
Expected: PASS and 0 errors.
- Step 4: Run the full suite
Run:
uv run pytest -q
Expected baseline before this feature: 643 passed, 1 skipped, 1 xfailed.
Expected after implementation: baseline plus the new run codec/store/durable
workflow tests, with the existing skip/xfail unchanged unless deliberately
addressed by another task.
Plan Self-Review
- Spec coverage: codec, typed durable records, stopped-run persistence, pinned root/child/deployment snapshots, blocked resume validation, compact inspect, bounded trace, and retry/timeout non-support all have explicit tasks/tests.
- Package direction:
wf_artifacts.runsis intentional for v1 because strongly typed run snapshots requireWorkflowArtifactandWorkflowDeployment, whilewf_artifactsalready depends onwf_platform. - Behavior boundary: no task checkpoints in-flight external calls or turns transport failure into resumable interruption.
- Existing-file pressure: lifecycle logic is explicitly extracted into
run_lifecycle.pyinstead of growinghandlers.pyfurther.