Files
lda-wf/docs/historical/superpowers/plans/2026-05-22-scheduler-foundation.md
T

757 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Scheduler Foundation 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:** Add the internal scheduler foundation needed for future concurrent foreach and native subgraphs while preserving current serial workflow behavior.
**Architecture:** `RunState.frames` remains the source of frame lifecycle state, and a new serialized `ready_frame_ids` queue defines deterministic scheduling order. A new internal `wf_core.runtime.scheduler` module owns frame creation, enqueue/select, block/wake, and no-ready-frame resolution. Existing sync and async engines both use scheduler selection before `prepare_step`; node execution stays split between sync and async paths.
**Tech Stack:** Python 3.14, dataclasses, pytest, Pydantic workflow models, existing `wf_core` runtime modules.
---
## Files
- Modify: `src/wf_core/run_state.py`
- Add `FrameStatus.BLOCKED`.
- Add `RunState.ready_frame_ids: list[str]`.
- Create: `src/wf_core/runtime/scheduler.py`
- Internal scheduler helpers and typed block/foreach metadata helpers.
- Modify: `src/wf_core/runtime/ops/runs.py`
- Initialize root frame and ready queue.
- Modify: `src/wf_core/runtime/ops/flow.py`
- Re-enqueue normal frame advances and complete terminal frames through scheduler helpers.
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Use typed foreach metadata, explicit child frame creation, block parent, enqueue child.
- Modify: `src/wf_core/runtime/ops/frames.py`
- Keep context helpers; demote stack-collapse usage or leave compatibility wrappers that call scheduler helpers.
- Modify: `src/wf_core/runtime/preparation.py`
- Stop relying on stack-style frame collapse for selection.
- Resume interrupt by waking/enqueueing the resumed frame.
- Modify: `src/wf_core/runtime/engine.py`
- Use scheduler loop for sync and async resume.
- Modify: `src/wf_core/runtime/step.py`
- Keep `prepare_step()` boundary; ensure selected frame is already chosen by scheduler.
- Test: `tests/core/test_scheduler.py`
- Unit tests for scheduler helpers.
- Test: `tests/core/test_run_state.py`
- Add serialization/additive field checks if needed.
- Test: existing foreach/interrupt tests under `tests/authoring/` and `tests/core/`
- Add focused regressions where the behavior is not already covered.
---
### Task 1: Add RunState Scheduler Fields
**Files:**
- Modify: `src/wf_core/run_state.py`
- Test: `tests/core/test_scheduler.py`
- [ ] **Step 1: Write failing tests for ready queue serialization and frame status**
Create `tests/core/test_scheduler.py` with:
```python
from __future__ import annotations
from wf_core.run_state import FrameStatus, RunState, RunStatus
def test_run_state_serializes_ready_frame_ids() -> None:
run = RunState(
workflow_name="demo",
status=RunStatus.PENDING,
workflow_input={},
state={},
ready_frame_ids=["root"],
)
dumped = run.to_dict()
assert dumped["ready_frame_ids"] == ["root"]
def test_frame_status_has_blocked() -> None:
assert FrameStatus.BLOCKED == "blocked"
```
- [ ] **Step 2: Run failing tests**
Run:
```bash
uv run pytest tests/core/test_scheduler.py -q
```
Expected: fails because `ready_frame_ids` and `FrameStatus.BLOCKED` do not exist.
- [ ] **Step 3: Implement minimal state fields**
In `src/wf_core/run_state.py`:
```python
class FrameStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
BLOCKED = "blocked"
COMPLETED = "completed"
FAILED = "failed"
INTERRUPTED = "interrupted"
```
Add to `RunState`:
```python
ready_frame_ids: list[str] = field(default_factory=list)
```
- [ ] **Step 4: Verify**
Run:
```bash
uv run pytest tests/core/test_scheduler.py -q
```
Expected: pass.
---
### Task 2: Create Internal Scheduler Helpers
**Files:**
- Create: `src/wf_core/runtime/scheduler.py`
- Test: `tests/core/test_scheduler.py`
- [ ] **Step 1: Add failing scheduler helper tests**
Append tests:
```python
import pytest
from wf_core.errors import WorkflowExecutionError
from wf_core.run_state import ExecutionFrame
from wf_core.runtime.scheduler import (
add_frame,
block_frame_on_children,
enqueue_frame,
select_next_frame,
wake_frame,
)
def _run() -> RunState:
return RunState(
workflow_name="demo",
status=RunStatus.RUNNING,
workflow_input={},
state={},
)
def test_add_frame_rejects_duplicate_frame_ids() -> None:
run = _run()
add_frame(run, ExecutionFrame(id="root", kind="root", node_id="a"))
with pytest.raises(WorkflowExecutionError, match="duplicate frame id"):
add_frame(run, ExecutionFrame(id="root", kind="root", node_id="a"))
def test_enqueue_is_unique_and_priority_moves_to_front() -> None:
run = _run()
add_frame(run, ExecutionFrame(id="a", kind="root", node_id="a"))
add_frame(run, ExecutionFrame(id="b", kind="root", node_id="b"))
enqueue_frame(run, "a")
enqueue_frame(run, "b")
enqueue_frame(run, "a")
enqueue_frame(run, "a", front=True)
assert run.ready_frame_ids == ["a", "b"]
def test_select_next_frame_pops_and_marks_running() -> None:
run = _run()
add_frame(run, ExecutionFrame(id="root", kind="root", node_id="a"))
enqueue_frame(run, "root")
frame = select_next_frame(run)
assert frame is not None
assert frame.id == "root"
assert frame.status == FrameStatus.RUNNING
assert run.ready_frame_ids == []
assert run.current_frame_id == "root"
assert run.current_node_id == "a"
def test_blocked_frame_is_not_selectable_until_woken() -> None:
run = _run()
add_frame(run, ExecutionFrame(id="parent", kind="root", node_id="foreach"))
block_frame_on_children(run, "parent", ("child",))
assert select_next_frame(run) is None
wake_frame(run, "parent")
selected = select_next_frame(run)
assert selected is not None
assert selected.id == "parent"
```
- [ ] **Step 2: Run failing tests**
Run:
```bash
uv run pytest tests/core/test_scheduler.py -q
```
Expected: fails because `wf_core.runtime.scheduler` does not exist.
- [ ] **Step 3: Implement scheduler helpers**
Create `src/wf_core/runtime/scheduler.py`:
```python
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from wf_core.errors import WorkflowExecutionError
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
@dataclass(slots=True, frozen=True)
class BlockedOnChildren:
"""Typed block reason for frames waiting on child frame completion."""
child_frame_ids: tuple[str, ...]
@classmethod
def from_frame(cls, frame: ExecutionFrame) -> "BlockedOnChildren | None":
raw = frame.metadata.get("blocked_on")
if raw is None:
return None
if not isinstance(raw, dict) or raw.get("type") != "child_frames":
raise WorkflowExecutionError(
f"malformed block reason for frame {frame.id!r}"
)
raw_ids = raw.get("frame_ids")
if not isinstance(raw_ids, list) or not all(
isinstance(item, str) for item in raw_ids
):
raise WorkflowExecutionError(
f"malformed child frame ids for frame {frame.id!r}"
)
return cls(tuple(raw_ids))
def to_metadata(self) -> dict[str, object]:
return {"type": "child_frames", "frame_ids": list(self.child_frame_ids)}
def add_frame(run: RunState, frame: ExecutionFrame, *, ready: bool = False) -> None:
"""Add a frame once; frame id reuse is always a runtime invariant error."""
if frame.id in run.frames:
raise WorkflowExecutionError(f"duplicate frame id {frame.id!r}")
run.frames[frame.id] = frame
if ready:
enqueue_frame(run, frame.id)
def enqueue_frame(run: RunState, frame_id: str, *, front: bool = False) -> None:
"""Put a pending frame in the ready queue without creating duplicates."""
frame = _frame(run, frame_id)
if frame.status != FrameStatus.PENDING:
raise WorkflowExecutionError(
f"cannot enqueue frame {frame_id!r} with status {frame.status!s}"
)
if frame_id in run.ready_frame_ids:
run.ready_frame_ids.remove(frame_id)
if front:
run.ready_frame_ids.insert(0, frame_id)
else:
run.ready_frame_ids.append(frame_id)
def select_next_frame(run: RunState) -> ExecutionFrame | None:
"""Select the next ready frame and expose it through compatibility cursor fields."""
while run.ready_frame_ids:
frame_id = run.ready_frame_ids.pop(0)
frame = _frame(run, frame_id)
if frame.status != FrameStatus.PENDING:
raise WorkflowExecutionError(
f"ready frame {frame_id!r} has status {frame.status!s}"
)
frame.status = FrameStatus.RUNNING
run.current_frame_id = frame.id
run.sync_from_current_frame()
return frame
return None
def mark_frame_pending(run: RunState, frame_id: str, *, front: bool = False) -> None:
"""Mark a live frame pending and enqueue it for future execution."""
frame = _frame(run, frame_id)
frame.status = FrameStatus.PENDING
enqueue_frame(run, frame_id, front=front)
def block_frame_on_children(
run: RunState, frame_id: str, child_frame_ids: Sequence[str]
) -> None:
"""Mark a frame blocked on child completion and remove it from the ready queue."""
frame = _frame(run, frame_id)
run.ready_frame_ids = [item for item in run.ready_frame_ids if item != frame_id]
frame.status = FrameStatus.BLOCKED
frame.metadata["blocked_on"] = BlockedOnChildren(
tuple(child_frame_ids)
).to_metadata()
def wake_frame(run: RunState, frame_id: str, *, front: bool = False) -> None:
"""Wake a blocked or interrupted frame and enqueue it as pending."""
frame = _frame(run, frame_id)
if frame.status not in {FrameStatus.BLOCKED, FrameStatus.INTERRUPTED}:
raise WorkflowExecutionError(
f"cannot wake frame {frame_id!r} with status {frame.status!s}"
)
frame.status = FrameStatus.PENDING
frame.metadata.pop("blocked_on", None)
enqueue_frame(run, frame_id, front=front)
def resolve_no_ready_frames(run: RunState) -> RunStatus:
"""Classify an empty ready queue into a terminal or blocked run state."""
if run.status == RunStatus.INTERRUPTED:
return RunStatus.INTERRUPTED
if any(frame.status == FrameStatus.FAILED for frame in run.frames.values()):
return RunStatus.FAILED
if run.frames and all(
frame.status == FrameStatus.COMPLETED for frame in run.frames.values()
):
return RunStatus.COMPLETED
if any(frame.status == FrameStatus.BLOCKED for frame in run.frames.values()):
raise WorkflowExecutionError("run has no ready frames and is deadlocked")
raise WorkflowExecutionError("run has no ready frames")
def _frame(run: RunState, frame_id: str) -> ExecutionFrame:
frame = run.frames.get(frame_id)
if frame is None:
raise WorkflowExecutionError(f"unknown frame id {frame_id!r}")
return frame
```
- [ ] **Step 4: Verify helper tests**
Run:
```bash
uv run pytest tests/core/test_scheduler.py -q
```
Expected: pass.
---
### Task 3: Initialize Root Through Scheduler
**Files:**
- Modify: `src/wf_core/runtime/ops/runs.py`
- Test: `tests/core/test_scheduler.py`
- [ ] **Step 1: Add root initialization test**
Append:
```python
from wf_core import SchemaRef, StateSchema, Workflow
from wf_core.runtime.ops.runs import create_run_state
def test_create_run_state_queues_root_frame() -> None:
workflow = Workflow(
name="demo",
input_schema=SchemaRef(properties={}),
state_schema=StateSchema(fields={}),
output_schema=SchemaRef(properties={}),
node_defs=[],
start="first",
nodes=[],
edges=[],
)
run = create_run_state(workflow, {})
assert run.current_frame_id == "root"
assert run.current_node_id == "first"
assert run.ready_frame_ids == ["root"]
assert run.frames["root"].status == FrameStatus.PENDING
```
- [ ] **Step 2: Run failing test if needed**
Run:
```bash
uv run pytest tests/core/test_scheduler.py::test_create_run_state_queues_root_frame -q
```
Expected: fails until root ready queue initialization exists.
- [ ] **Step 3: Update run creation**
In `src/wf_core/runtime/ops/runs.py`, create the root frame through `add_frame(..., ready=True)` and set compatibility cursor fields.
- [ ] **Step 4: Verify**
Run:
```bash
uv run pytest tests/core/test_scheduler.py -q
```
Expected: pass.
---
### Task 4: Re-Enqueue Normal Frame Advances
**Files:**
- Modify: `src/wf_core/runtime/ops/flow.py`
- Modify: `src/wf_core/runtime/step.py`
- Test: `tests/core/test_scheduler.py`
- [ ] **Step 1: Add normal advance test**
Append:
```python
from wf_core.runtime.ops.flow import advance_frame
def test_advance_frame_requeues_non_terminal_frame() -> None:
run = _run()
add_frame(run, ExecutionFrame(id="root", kind="root", node_id="a"))
run.current_frame_id = "root"
run.sync_from_current_frame()
frame = run.current_frame()
frame.status = FrameStatus.RUNNING
advance_frame(run, frame, outcome="ok", next_node_id="b")
assert frame.status == FrameStatus.PENDING
assert run.ready_frame_ids == ["root"]
assert run.current_node_id == "b"
```
- [ ] **Step 2: Run failing test**
Run:
```bash
uv run pytest tests/core/test_scheduler.py::test_advance_frame_requeues_non_terminal_frame -q
```
Expected: fails because `advance_frame` currently leaves non-terminal status as running and does not enqueue.
- [ ] **Step 3: Update `advance_frame`**
In `src/wf_core/runtime/ops/flow.py`, after setting node state:
```python
from wf_core.runtime.scheduler import mark_frame_pending
```
Use:
```python
if next_node_id == END:
frame.status = FrameStatus.COMPLETED
frame.finished_at_node_id = END
else:
frame.finished_at_node_id = None
mark_frame_pending(run, frame.id)
run.sync_from_current_frame()
```
- [ ] **Step 4: Verify focused tests**
Run:
```bash
uv run pytest tests/core/test_scheduler.py -q
```
Expected: pass.
---
### Task 5: Migrate Engine Loops To Scheduler Selection
**Files:**
- Modify: `src/wf_core/runtime/engine.py`
- Modify: `src/wf_core/runtime/preparation.py`
- Modify: `src/wf_core/runtime/step.py`
- Test: existing workflow tests
- [ ] **Step 1: Add regression tests if existing coverage is insufficient**
Before adding new tests, run:
```bash
uv run pytest tests/authoring/test_demo_workflow.py tests/core/test_scheduler.py -q
```
Expected before implementation: likely failures after Task 4 until engine selection is updated.
- [ ] **Step 2: Update engine loops**
Change `resume_workflow` and `resume_workflow_async` to:
```python
while True:
frame = select_next_frame(run)
if frame is None:
status = resolve_no_ready_frames(run)
if status == RunStatus.COMPLETED:
break
return run
step_workflow(...)
if run.status == RunStatus.INTERRUPTED:
return run
```
Use the same selection flow in the async loop before `await step_workflow_async(...)`.
- [ ] **Step 3: Remove stack-style frame collapse from step preparation**
In `prepare_step`, remove old stack-style frame collapse and keep it focused on resolving the selected frames node. It should still return `None` for interrupted/end states.
- [ ] **Step 4: Verify serial workflows**
Run:
```bash
uv run pytest tests/authoring/test_demo_workflow.py tests/core/test_scheduler.py -q
```
Expected: pass.
---
### Task 6: Make Serial Foreach Use Block/Wake
**Files:**
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Modify: `src/wf_core/runtime/ops/frames.py`
- Create or extend: `tests/core/test_scheduler.py`
- Test: `tests/authoring/test_demo_workflow.py`
- [ ] **Step 1: Add foreach block/wake regression test**
Add a focused test using an existing demo workflow or a small builder workflow that asserts:
```python
foreach_entries = [entry for entry in run.trace if entry.step_type == "foreach"]
assert foreach_entries
assert all(frame.status != FrameStatus.BLOCKED for frame in run.frames.values())
assert any(frame.kind == "foreach_iteration" for frame in run.frames.values())
```
Then add a lower-level test if needed for child completion waking parent:
```python
def test_child_completion_wakes_blocked_parent() -> None:
run = _run()
add_frame(run, ExecutionFrame(id="parent", kind="root", node_id="foreach"))
add_frame(
run,
ExecutionFrame(
id="child",
kind="foreach_iteration",
node_id="__end__",
parent_frame_id="parent",
),
)
block_frame_on_children(run, "parent", ("child",))
run.frames["child"].status = FrameStatus.COMPLETED
wake_parent_if_children_complete(run, "child")
assert run.frames["parent"].status == FrameStatus.PENDING
assert run.ready_frame_ids == ["parent"]
```
- [ ] **Step 2: Implement typed foreach metadata**
In `src/wf_core/runtime/scheduler.py` or a small sibling module if the file grows too large:
```python
@dataclass(slots=True, frozen=True)
class ForeachIterationMetadata:
foreach_node_id: str
loop_index: int
loop_item: object
loop_alias: str
@classmethod
def from_frame(cls, frame: ExecutionFrame) -> "ForeachIterationMetadata | None":
if frame.kind != "foreach_iteration":
return None
...
def to_metadata(self) -> dict[str, object]:
...
```
Wrong frame kind returns `None`; malformed foreach iteration metadata raises `WorkflowExecutionError`.
- [ ] **Step 3: Implement parent wake helper**
Add:
```python
def wake_parent_if_children_complete(run: RunState, child_frame_id: str) -> None:
child = _frame(run, child_frame_id)
parent_id = child.parent_frame_id
if parent_id is None:
return
parent = _frame(run, parent_id)
block = BlockedOnChildren.from_frame(parent)
if block is None:
return
if all(run.frames[item].status == FrameStatus.COMPLETED for item in block.child_frame_ids):
wake_frame(run, parent_id)
```
- [ ] **Step 4: Update foreach child creation**
In `step_foreach`, replace direct `run.frames[child_id] = ...` with `add_frame(..., ready=True)`, then call `block_frame_on_children(run, frame.id, (child_id,))`. Parent should not stay in ready queue while child runs.
- [ ] **Step 5: Verify foreach behavior**
Run:
```bash
uv run pytest tests/authoring/test_demo_workflow.py tests/core/test_scheduler.py -q
```
Expected: pass.
---
### Task 7: Resume Interrupt Through Ready Queue
**Files:**
- Modify: `src/wf_core/runtime/preparation.py`
- Modify: `src/wf_core/runtime/ops/interrupts.py`
- Test: existing interrupt tests or new focused tests
- [ ] **Step 1: Find existing interrupt tests**
Run:
```bash
rg -n "interrupt|resume_payload|resume_outcome" tests src -g '*.py'
```
- [ ] **Step 2: Add/adjust test for resume priority**
Add a focused test that creates an interrupted frame plus another ready frame and verifies resume places interrupted frame first:
```python
def test_resume_wakes_interrupted_frame_at_front() -> None:
run = _run()
add_frame(run, ExecutionFrame(id="waiting", kind="root", node_id="ask"))
add_frame(run, ExecutionFrame(id="sibling", kind="root", node_id="work"))
run.frames["waiting"].status = FrameStatus.INTERRUPTED
run.frames["sibling"].status = FrameStatus.PENDING
run.ready_frame_ids = ["sibling"]
wake_frame(run, "waiting", front=True)
assert run.ready_frame_ids == ["waiting", "sibling"]
```
- [ ] **Step 3: Update resume code**
After `resume_interrupt(...)`, wake/enqueue the resumed frame at the front. Keep one outstanding `run.interrupt`.
- [ ] **Step 4: Verify interrupt tests**
Run focused interrupt tests found in Step 1 plus:
```bash
uv run pytest tests/core/test_scheduler.py -q
```
Expected: pass.
---
### Task 8: Full Verification
**Files:**
- Potentially update docs if implementation differs from ADR.
- [ ] **Step 1: Run focused workflow tests**
Run:
```bash
uv run pytest tests/core/test_scheduler.py tests/authoring/test_demo_workflow.py -q
```
Expected: pass.
- [ ] **Step 2: Run full test suite**
Run:
```bash
uv run pytest -q
```
Expected: pass, except known environment-only skips.
- [ ] **Step 3: Run lint/type checks**
Run:
```bash
uvx ruff check src tests
uv run basedpyright --level error
```
Expected: ruff passes and basedpyright reports 0 errors.
- [ ] **Step 4: Review docs**
Check:
```bash
git diff -- CONTEXT.md docs/adr/0001-scheduler-foundation-before-concurrent-foreach.md docs/current_roadmap.md
```
Expected: docs remain aligned with implemented first-pass behavior.
---
## Self-Review
- Spec coverage: ADR decisions are covered by tasks for `BLOCKED`, ready queue, scheduler helpers, sync/async engine migration, serial foreach block/wake, interrupt resume priority, and verification.
- Intentional gaps: no `foreach(mode="concurrent")`, no `ForeachConcurrentPolicy`, no lineage patches, no BarrierNode, and no public scheduler exports.
- Type consistency: helper names are stable across tasks: `add_frame`, `enqueue_frame`, `select_next_frame`, `mark_frame_pending`, `block_frame_on_children`, `wake_frame`, `wake_parent_if_children_complete`, and `resolve_no_ready_frames`.