Files
lda-wf/docs/historical/superpowers/plans/2026-05-26-durable-workflow-runs-and-resume.md
T

1105 lines
35 KiB
Markdown

# 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/<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:
```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.