# 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_deployment` or `resume_run` stops as `interrupted`, `completed`, or `failed`. - V1 does not persist mid-tool-call or per-node checkpoints. - `unrunnable` remains a pre-start response and does not create a run record. - Only an explicit `InterruptNode` produces 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 `interrupted` with blocked resume readiness and diagnostics. - Existing `retry`, `timeout_seconds`, and `retry_count` declarations remain unsupported runtime policy. ## Target File Layout ```text 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: ```python 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: ```bash 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`: ```python 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: ```bash 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`: ```python 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: ```bash 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`: ```python 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 `RunStore` and `FileRunStore`** Create `src/wf_artifacts/runs/store.py` with: ```python 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//run.json` and each checkpoint at `runs//checkpoints/.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: ```bash 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: ```python 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: ```bash 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: ```python 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: ```python # 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: ```bash 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: ```python 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: ```python 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: ```bash 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: ```python 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: ```python 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_store` dependency to `WfMcpService`** In `src/wf_mcp/broker/service/core.py`, add: ```python run_store: RunStore | None = None ``` In `__post_init__`, default it to: ```python 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_runs` use in the workflow surface** In `handlers.py`: - remove `ActiveWorkflowRun` - remove `self._active_runs` - on successful start execution, call `persist_stopped_run(...)` - on resume, load `WorkflowRunRecord` plus checkpoint through `restore_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: ```bash 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** ```python 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: ```bash 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: ```python 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: ```bash 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: ```python 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: ```bash 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: ```python 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: ```text 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: ```bash 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_run` and `read_run_trace` are 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: ```text 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: ```bash 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: ```bash 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.runs` is intentional for v1 because strongly typed run snapshots require `WorkflowArtifact` and `WorkflowDeployment`, while `wf_artifacts` already depends on `wf_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.py` instead of growing `handlers.py` further.