# Resume Run Concurrency Guard 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:** Prevent two concurrent `resume_run` calls in the same server process from both advancing the same interrupted run from the same checkpoint. **Architecture:** Add a small process-local keyed async lock helper in `wf_api`, then wrap `WorkflowRunApi.resume_run()` with a critical section keyed by `run_id`. This intentionally protects one long-lived API process; it does not pretend the JSON filesystem store is safe for multi-process/cloud concurrency, which remains a transactional-store follow-up. **Tech Stack:** Python 3.14, `asyncio.Lock`, pytest, pytest-asyncio, `wf_api`, `wf_artifacts.FileRunStore`. --- ## File Structure - Create `src/wf_api/run_locks.py` - Owns one focused helper, `AsyncKeyedLock`, for process-local async critical sections keyed by string ids. - No dependency on run models or stores. - Modify `src/wf_api/runs.py` - Instantiate a resume lock registry on `WorkflowRunApi`. - Wrap the existing resume implementation in `async with self._resume_locks.lock(run_id)`. - Add a short comment explaining why the lock lives above `RunStore`. - Create `tests/wf_api/test_run_locks.py` - Tests lock serialization for the same key and concurrency for different keys. - Create `tests/wf_api/test_resume_concurrency.py` - Seeds an interrupted run directly in `FileRunStore`. - Uses a fake runtime that blocks inside `resume_workflow_from_plan`. - Starts two concurrent resumes for the same `run_id`. - Asserts only the first reaches runtime and the second rejects after the first persists completion. - Modify `docs/superpowers/specs/2026-06-03-persisted-run-resume-contract.md` - Mark the process-local guard as the next concrete slice. - Keep the transactional-store limitation explicit. - Modify `docs/current_roadmap.md` - Link this active implementation plan under Priority 2. --- ### Task 1: Add Keyed Async Lock Tests **Files:** - Create: `tests/wf_api/test_run_locks.py` - [ ] **Step 1: Write failing tests for the lock helper** Create `tests/wf_api/test_run_locks.py`: ```python from __future__ import annotations import asyncio from wf_api.run_locks import AsyncKeyedLock async def test_async_keyed_lock_serializes_same_key() -> None: locks = AsyncKeyedLock() entered: list[str] = [] release_first = asyncio.Event() async def first() -> None: async with locks.lock("run_123"): entered.append("first") await release_first.wait() async def second() -> None: async with locks.lock("run_123"): entered.append("second") first_task = asyncio.create_task(first()) await asyncio.sleep(0) second_task = asyncio.create_task(second()) await asyncio.sleep(0) assert entered == ["first"] release_first.set() await asyncio.gather(first_task, second_task) assert entered == ["first", "second"] async def test_async_keyed_lock_allows_different_keys_concurrently() -> None: locks = AsyncKeyedLock() entered: list[str] = [] release = asyncio.Event() async def hold(key: str) -> None: async with locks.lock(key): entered.append(key) await release.wait() first_task = asyncio.create_task(hold("run_a")) second_task = asyncio.create_task(hold("run_b")) await asyncio.sleep(0) assert entered == ["run_a", "run_b"] release.set() await asyncio.gather(first_task, second_task) ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```bash uv run pytest tests/wf_api/test_run_locks.py -q ``` Expected: FAIL with `ModuleNotFoundError: No module named 'wf_api.run_locks'`. --- ### Task 2: Implement `AsyncKeyedLock` **Files:** - Create: `src/wf_api/run_locks.py` - [ ] **Step 1: Add the keyed lock helper** Create `src/wf_api/run_locks.py`: ```python from __future__ import annotations import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass @dataclass(slots=True) class _LockEntry: lock: asyncio.Lock users: int = 0 class AsyncKeyedLock: """Process-local async critical sections keyed by a stable string id. This helper intentionally does not provide cross-process locking. Use it for one API/server process; use a transactional store for multi-worker safety. """ def __init__(self) -> None: self._guard = asyncio.Lock() self._entries: dict[str, _LockEntry] = {} @asynccontextmanager async def lock(self, key: str) -> AsyncIterator[None]: """Hold the critical section for `key` until the context exits.""" entry = await self._retain(key) await entry.lock.acquire() try: yield finally: entry.lock.release() await self._release(key, entry) async def _retain(self, key: str) -> _LockEntry: async with self._guard: entry = self._entries.get(key) if entry is None: entry = _LockEntry(lock=asyncio.Lock()) self._entries[key] = entry entry.users += 1 return entry async def _release(self, key: str, entry: _LockEntry) -> None: async with self._guard: entry.users -= 1 if entry.users == 0 and not entry.lock.locked(): self._entries.pop(key, None) ``` - [ ] **Step 2: Run keyed lock tests** Run: ```bash uv run pytest tests/wf_api/test_run_locks.py -q ``` Expected: PASS, `2 passed`. - [ ] **Step 3: Run lint/typecheck on the new helper** Run: ```bash uv run ruff check src/wf_api/run_locks.py tests/wf_api/test_run_locks.py uv run basedpyright --level error src/wf_api/run_locks.py tests/wf_api/test_run_locks.py ``` Expected: ```text All checks passed! 0 errors, 0 warnings, 0 notes ``` - [ ] **Step 4: Commit the helper** Run: ```bash git add src/wf_api/run_locks.py tests/wf_api/test_run_locks.py git commit -m "feat: add async keyed lock helper" ``` --- ### Task 3: Add Same-Run Resume Race Regression Test **Files:** - Create: `tests/wf_api/test_resume_concurrency.py` - [ ] **Step 1: Write the failing resume race test** Create `tests/wf_api/test_resume_concurrency.py`: ```python from __future__ import annotations import asyncio from pathlib import Path from typing import Any import pytest from wf_api.models import RawWorkflowPlan from wf_api.operation_context import WorkflowOperationContext from wf_api.run_lifecycle import create_pinned_environment, persist_stopped_run from wf_api.runs import WorkflowRunApi from wf_api.saved_subgraphs import SavedSubgraphTree from wf_artifacts import ( FileRunStore, WorkflowArtifact, WorkflowDeployment, ) from wf_authoring import NodeSpec from wf_core import InterruptRequest, RunState, RunStatus from wf_platform import CapabilityBuckets, CapabilitySource class DummyEvents: def record_event(self, event: object) -> None: pass def record_workflow_event( self, event_type: str, *, capability_id: str, payload: dict[str, Any], ) -> None: pass class EmptySpecProvider: @property def capability_sources(self) -> dict[str, CapabilitySource]: return {} def get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]: raise KeyError(f"unknown capability {qualified_name!r}") class BlockingResumeRuntime: def __init__(self) -> None: self.entered = 0 self.first_entered = asyncio.Event() self.release_first = asyncio.Event() async def run_workflow_from_plan( self, plan: RawWorkflowPlan, workflow_input: dict[str, Any], deployment: WorkflowDeployment | None = None, artifact: WorkflowArtifact | None = None, saved_subgraph_tree: SavedSubgraphTree | None = None, ) -> RunState: raise AssertionError("test should not start new workflow runs") async def resume_workflow_from_plan( self, plan: RawWorkflowPlan, run: RunState, *, resume_payload: dict[str, Any], resume_outcome: str, deployment: WorkflowDeployment | None = None, artifact: WorkflowArtifact | None = None, saved_subgraph_tree: SavedSubgraphTree | None = None, ) -> RunState: self.entered += 1 self.first_entered.set() await self.release_first.wait() return RunState( workflow_name=plan.name, status=RunStatus.COMPLETED, workflow_input=run.workflow_input, state={"answer": resume_payload["answer"]}, outcome=resume_outcome, output={"answer": resume_payload["answer"]}, ) def test_resume_run_serializes_same_run_attempts(tmp_path: Path) -> None: async def scenario() -> None: store = FileRunStore(tmp_path / "runs") runtime = BlockingResumeRuntime() run_id = _seed_interrupted_run(store) api = WorkflowRunApi( WorkflowOperationContext( artifact_store=None, draft_workspace_store=None, run_store=store, events=DummyEvents(), specs=EmptySpecProvider(), runtime=runtime, live_sources=None, ) ) first = asyncio.create_task( api.resume_run(run_id=run_id, resume_payload={"answer": "first"}) ) await runtime.first_entered.wait() second = asyncio.create_task( api.resume_run(run_id=run_id, resume_payload={"answer": "second"}) ) await asyncio.sleep(0) assert runtime.entered == 1 runtime.release_first.set() first_payload = await first assert first_payload["status"] == "completed" assert first_payload["output"] == {"answer": "first"} with pytest.raises(ValueError, match="is not interrupted"): await second assert runtime.entered == 1 assert store.get_latest_checkpoint(run_id).sequence == 2 assert len(store.list_checkpoints(run_id)) == 2 asyncio.run(scenario()) def _seed_interrupted_run(store: FileRunStore) -> str: artifact = _artifact() deployment = WorkflowDeployment( id="pause.default", artifact_id=artifact.id, artifact_version=artifact.version, bindings=[], ) interrupted = RunState( workflow_name="pause", status=RunStatus.INTERRUPTED, workflow_input={"question": "continue?"}, state={}, interrupt=InterruptRequest( id="interrupt:approval", frame_id="root", node_id="approval", kind="approval", payload={"question": "continue?"}, ), ) record = persist_stopped_run( store=store, environment=create_pinned_environment( deployment=deployment, artifact=artifact, tree=SavedSubgraphTree(artifacts_by_ref={}), ), run=interrupted, ) return record.id def _artifact() -> WorkflowArtifact: return WorkflowArtifact( id="pause", version=1, title="Pause", input_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}}, outcomes=("ok", "submitted"), plan={ "name": "pause", "input_schema": {"type": "object", "properties": {}}, "state_schema": {"type": "object", "properties": {}}, "output_schema": {"type": "object", "properties": {}}, "outcomes": ["ok", "submitted"], "start": "end_submitted", "nodes": [ {"id": "end_submitted", "type": "end", "outcome": "submitted"} ], "edges": [], }, ) ``` - [ ] **Step 2: Run the test to verify the race exists** Run: ```bash uv run pytest tests/wf_api/test_resume_concurrency.py -q ``` Expected before wiring the lock: FAIL. The likely failure is: ```text assert runtime.entered == 1 E assert 2 == 1 ``` If the failure appears later as duplicate checkpoint sequence or final output from `"second"`, that is also acceptable. The test must fail because the second concurrent resume reaches runtime or writes from the same starting checkpoint. --- ### Task 4: Wrap `WorkflowRunApi.resume_run` With the Keyed Lock **Files:** - Modify: `src/wf_api/runs.py` - [ ] **Step 1: Import and instantiate the lock helper** In `src/wf_api/runs.py`, add the import: ```python from .run_locks import AsyncKeyedLock ``` Change the constructor from: ```python def __init__(self, context: WorkflowOperationContext) -> None: self.context = context self.deployments = WorkflowDeploymentApi(context) ``` to: ```python def __init__( self, context: WorkflowOperationContext, *, resume_locks: AsyncKeyedLock | None = None, ) -> None: self.context = context self.deployments = WorkflowDeploymentApi(context) self._resume_locks = resume_locks or AsyncKeyedLock() ``` - [ ] **Step 2: Split the unlocked resume body into a private helper** Replace the current `resume_run` method with this wrapper plus private method: ```python async def resume_run( self, *, run_id: str, resume_payload: dict[str, Any], resume_outcome: str = "submitted", trace_range: TraceRangeLike | None = None, ) -> dict[str, Any]: """Resume one durable interrupted deployment run.""" # FileRunStore locks individual file writes only. The API layer owns the # process-local read/execute/write critical section for one run id. async with self._resume_locks.lock(run_id): return await self._resume_run_unlocked( run_id=run_id, resume_payload=resume_payload, resume_outcome=resume_outcome, trace_range=trace_range, ) async def _resume_run_unlocked( self, *, run_id: str, resume_payload: dict[str, Any], resume_outcome: str, trace_range: TraceRangeLike | None, ) -> dict[str, Any]: trace_values = _trace_range_values(trace_range) record, stopped_run = restore_interrupted_run(self._run_store(), run_id) environment = record.environment diagnostics = validate_pinned_resume_environment( record=record, sources=_available_sources(self.context.specs.capability_sources), ) if has_blocking_diagnostics(diagnostics): blocked = mark_resume_blocked( store=self._run_store(), record=record, diagnostics=diagnostics, ) return _run_payload( deployment=environment.deployment, artifact=environment.root_artifact, status=stopped_run.status.value, run_id=blocked.id, resume_readiness=blocked.resume_readiness.value, interrupt=_interrupt_payload(stopped_run), outcome=stopped_run.outcome, error=stopped_run.error, output=stopped_run.output, diagnostics=diagnostics, trace_count=len(stopped_run.trace), ) plan = raw_plan_from_artifact(environment.root_artifact) tree = saved_subgraph_tree_from_snapshots(environment.child_artifacts) run = await self.context.runtime.resume_workflow_from_plan( plan, stopped_run, resume_payload=resume_payload, resume_outcome=resume_outcome, deployment=environment.deployment, artifact=environment.root_artifact, saved_subgraph_tree=tree, ) next_record = persist_stopped_run( store=self._run_store(), environment=environment, run=run, run_id=run_id, ) return _run_payload( deployment=environment.deployment, artifact=environment.root_artifact, status=run.status.value, run_id=next_record.id, resume_readiness=next_record.resume_readiness.value, interrupt=_interrupt_payload(run), outcome=run.outcome, error=run.error, output=run.output, trace_count=len(run.trace), **_trace_slice_fields(run, trace_values), ) ``` The body is the existing implementation moved under `_resume_run_unlocked`; do not change blocked-resume or persistence semantics. - [ ] **Step 3: Run the resume race test** Run: ```bash uv run pytest tests/wf_api/test_resume_concurrency.py -q ``` Expected: PASS, `1 passed`. - [ ] **Step 4: Run existing run API tests** Run: ```bash uv run pytest tests/wf_api/test_run_api.py tests/wf_api/test_resume_concurrency.py tests/wf_api/test_run_locks.py -q ``` Expected: all tests pass. - [ ] **Step 5: Commit the API wiring** Run: ```bash git add src/wf_api/runs.py tests/wf_api/test_resume_concurrency.py git commit -m "fix: serialize same-run resume attempts" ``` --- ### Task 5: Update Durable Resume Docs **Files:** - Modify: `docs/superpowers/specs/2026-06-03-persisted-run-resume-contract.md` - Modify: `docs/current_roadmap.md` - [ ] **Step 1: Update the resume contract current implementation section** In `docs/superpowers/specs/2026-06-03-persisted-run-resume-contract.md`, under `### resume_run`, change the current implementation bullets to: ```markdown Current implementation: - `WorkflowRunApi.resume_run()` follows this contract for stored interrupted runs and blocked dependency validation. - Same-process callers are serialized per `run_id` around the restore, dependency validation, runtime resume, and checkpoint write sequence. - `restore_interrupted_run()` rejects non-interrupted statuses. - `mark_resume_blocked()` updates run summary without writing a checkpoint. ``` - [ ] **Step 2: Update the store contract limitation** In the same spec, replace the `Current limits:` list under `## Store Contract` with: ```markdown Current limits: - `WorkflowRunApi.resume_run()` provides a process-local per-run critical section for one API/server process. - `FileRunStore` locks individual file writes only; it does not provide compare-and-swap or cross-process transactions. - The file store is appropriate for local/dev/single-process use. - A multi-worker or cloud deployment still needs SQLite/Postgres or another transactional store before claiming strong concurrent resume safety. ``` - [ ] **Step 3: Update the gap list** In the same spec, replace gap item `4. **Resume concurrency guard**` with: ```markdown 4. **Resume concurrency guard** - Implemented for same-process API callers through a per-`run_id` async critical section in `WorkflowRunApi.resume_run()`. - Cross-process protection remains part of the transactional backend gap. ``` - [ ] **Step 4: Update the roadmap** In `docs/current_roadmap.md`, under `## Priority 2: Durable Run/Resume Hardening`, replace: ```markdown - Add same-run concurrency protection around `resume_run`. - Clarify store-level locking/transaction expectations for filesystem stores. ``` with: ```markdown - Completed: same-process `resume_run` calls are serialized per run id. Implementation: [`resume run concurrency guard`](historical/superpowers/plans/2026-06-09-resume-run-concurrency-guard.md). - Clarify store-level locking/transaction expectations for future filesystem and transactional stores. ``` - [ ] **Step 5: Move this plan to historical after implementation** After all code and docs pass verification, move this plan: ```bash git mv docs/superpowers/plans/2026-06-09-resume-run-concurrency-guard.md docs/historical/superpowers/plans/2026-06-09-resume-run-concurrency-guard.md ``` Then commit docs: ```bash git add docs/current_roadmap.md docs/superpowers/specs/2026-06-03-persisted-run-resume-contract.md docs/historical/superpowers/plans/2026-06-09-resume-run-concurrency-guard.md git commit -m "docs: record resume concurrency guard" ``` --- ### Task 6: Final Verification **Files:** - No new files. - [ ] **Step 1: Run focused tests** Run: ```bash uv run pytest tests/wf_api/test_run_locks.py tests/wf_api/test_resume_concurrency.py tests/wf_api/test_run_api.py tests/wf_mcp/test_saved_subgraphs.py tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py -q ``` Expected: all selected tests pass. - [ ] **Step 2: Run broader API tests** Run: ```bash uv run pytest tests/wf_api -q ``` Expected: all `wf_api` tests pass. - [ ] **Step 3: Run lint and typecheck** Run: ```bash uv run ruff check src/wf_api tests/wf_api uv run ruff format --check src/wf_api tests/wf_api uv run basedpyright --level error src/wf_api tests/wf_api/test_run_locks.py tests/wf_api/test_resume_concurrency.py tests/wf_api/test_run_api.py ``` Expected: ```text All checks passed! 0 errors, 0 warnings, 0 notes ``` - [ ] **Step 4: Inspect git status** Run: ```bash git status --short ``` Expected: clean working tree after the commits above. --- ## Self-Review - Spec coverage: The plan implements the roadmap gap "same-run concurrency protection around `resume_run`" and explicitly preserves the store-level transactional limitation. - Placeholder scan: No `TBD`, vague "handle errors", or unspecified test work remains. - Type consistency: `AsyncKeyedLock`, `WorkflowRunApi.resume_run`, `RawWorkflowPlan`, `WorkflowOperationContext`, `FileRunStore`, and `SavedSubgraphTree` names match current code. - Scope control: This plan does not add run listing, checkpoint listing, retries, cloud transactions, or public API shape changes.