730 lines
22 KiB
Markdown
730 lines
22 KiB
Markdown
# 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.
|