sched: docs, probe retirement, MCP guard, coverage pins, executable example (T14)

This commit is contained in:
lda
2026-09-09 12:21:13 +07:00 Verified
parent e09c08899a
commit 9c4f7de086
17 changed files with 601 additions and 2398 deletions
+2
View File
@@ -39,6 +39,8 @@ implementation plans are kept for context, not as active instructions.
deployments, dependency compatibility, and interrupt limitations.
- [`durable_run_operations.md`](durable_run_operations.md): `run_deployment`,
`inspect_run`, bounded trace reads, and `resume_run` behavior.
- [`deployment_scheduling.md`](deployment_scheduling.md): opt-in schedule
administration, occurrence inspection, and server lifecycle behavior.
- [`workflow_drafts.md`](workflow_drafts.md): LLM/human draft authoring format
above raw workflow plans.
+10
View File
@@ -149,6 +149,16 @@ The active sequence can assume these foundations:
- Python client reconstruction of capabilities, artifacts, deployments, and
runs through the API
The active sequence can also assume deployment scheduling: an opt-in
same-server scheduler starts ordinary deployment runs without a connected
client (one-shot and recurring cron, durable admission, coalesced
missed-start recovery, bounded parallel runs, occurrence inspection, and
API/Python-client administration). Scheduling is disabled by default and
enabled per server. The current contract is
[`deployment scheduling`](superpowers/specs/2026-09-08-deployment-scheduling-design.md);
operator usage is under
[`deployment scheduling operations`](deployment_scheduling.md).
The current foreach return contract is
[`foreach back-edge design`](superpowers/specs/2026-09-04-foreach-back-edge-design.md).
The current context contract is
+198
View File
@@ -0,0 +1,198 @@
# Deployment Scheduling Operations
Schedules start ordinary deployment runs without a connected client. The
scheduler is opt-in, lives in the workflow server, and introduces no
workflow node type. It is not the core runtime's frame scheduler.
Current contract:
[`deployment scheduling spec`](superpowers/specs/2026-09-08-deployment-scheduling-design.md).
## Enablement
Scheduling is disabled by default. Enable it for a local/static server
with the config section or the CLI flag (MCP-backed servers reject it):
```json
{"server": {"scheduler": {"enabled": true}}}
```
```bash
wf-rpc-server --store-root .wf_store --enable-scheduler
```
Tuning (`server.scheduler`): `poll_interval_s` (default 1.0),
`max_concurrent_runs` (default 4, the execution-slot bound),
`drain_grace_s` (default 30.0). Schedule data lives at
`<store_root>/schedules` next to run data; one lock file at
`<store_root>/scheduler.lock` proves exclusive ownership. A second
scheduler over the same stores is rejected; shut the first down before
starting another.
## Mental model: schedule vs occurrence vs run
- A **schedule** is a durable definition: deployment, trigger
(one-shot or cron), input bindings, overlap/misfire policies, and a
revision. Edits bump the revision and affect only future admissions.
- An **occurrence** is one resolved calendar instant, identified by
`(schedule_id, resolved UTC instant)`. An occurrence is immutable once
admitted and is never replayed.
- A **run** is the execution of one admitted occurrence, with a
store-backed `run-000001`-style id, a pinned input/artifact snapshot,
and a stopped checkpoint when it stops.
## Triggers and time zones
One-shot timestamps must include an offset. Cron uses five-field Unix
expressions (`0` and `7` both mean Sunday; day-of-month/day-of-week
match with OR) plus an explicit IANA time zone, default UTC.
Occurrence instants persist as UTC; the definition keeps the zone name.
`croniter` owns calendar resolution including DST gaps and folds; there
is no custom calendar filtering. Reject invalid zones and naive times.
## Overlap
Overlap is per schedule, not per deployment. With default
`overlap="skip"`, an unfinished scheduled run — including an
interrupted run awaiting input — blocks another occurrence from that
schedule. Manual runs and other schedules never participate.
`overlap="parallel"` admits independent runs up to a required positive
`max_active_runs`; admitted, running, and interrupted runs all count,
and lowering the limit blocks new admission without cancelling existing
runs. A known completed or failed run releases overlap.
## Misfire
Default `misfire="skip"` drops missed starts (past the 60-second
configurable lateness allowance). `misfire="latest"` retains at most
one latest unadmitted candidate per schedule and runs it as soon as
overlap and capacity allow; it never replays a burst. Creation never
backfills time before the revision: the consumed watermark starts at
creation.
## Pause
Pausing stops future admission, not an active run. Resume selects the
next future occurrence; paused times are not replayed. Pause is not
downtime: the paused span is consumed, so downtime catch-up never
resurrects it.
## Deletion
Deleting a schedule stops future admission. Existing runs and
occurrence history survive, including their schedule identity; active
runs continue and may still complete or resume against the retained
history. Schedule ids are never reused.
## Failure and restart
An abandoned in-flight execution (the process died mid-run) becomes
failed without retrying its occurrence; the failure discloses that
external effects may already have occurred. A durably interrupted run
is not abandoned: it stays resumable and keeps occupying its
schedule's overlap slot across restarts. Every resume marks a
store-backed attempt first, so recovery can tell a fresh result from a
stale checkpoint and fail the ambiguous case closed instead of
retrying it. Corrupt or contradictory records fail closed with
diagnostics and block the schedule rather than clearing overlap.
On shutdown the server stops admission first and drains active tasks
within the grace period; anything still running keeps its executing
mark, and the next startup recovery abandons it truthfully.
## Occurrence inspection
`list_schedule_occurrences` pages stored history (`pending`,
`coalesced`/`superseded`, `skipped-overlap`, `skipped-misfire`,
`preflight-rejected`, `admitted`/`running`, `interrupted`,
`completed`, `failed`, `exhausted`) oldest-first with a `next_cursor`.
A currently held (unadmitted) candidate is synthesized as a `pending`
row at the top of the first page; once admitted, the durable
`admitted` entry replaces it. While a candidate is held, the first
page may carry one row more than `limit`.
## Administration surface
Python API (`server.api.schedules`), JSON-RPC (`workflow.schedules.*`),
and the Python client (`App` schedule methods) share these operations:
- `create_schedule` (`workflow.schedules.create`): caller-chosen id;
trigger, deployment, binding, and sample-schema checks.
- `get_schedule` (`workflow.schedules.get`): full definition payload.
- `list_schedules` (`workflow.schedules.list`): deleted excluded
unless asked.
- `update_schedule` (`workflow.schedules.update`): `expected_revision`
required; provided fields only; `None` means unpatched.
- `pause_schedule` (`workflow.schedules.pause`): idempotent; clears
candidates, consumes the span.
- `resume_schedule` (`workflow.schedules.resume`): idempotent; resumes
from the next future instant.
- `delete_schedule` (`workflow.schedules.delete`): soft delete; runs
and history survive.
- `list_schedule_occurrences`
(`workflow.schedules.occurrences.list`): cursor pages, `limit` 1100,
live pending synthesis.
`inspect_run` also reads admitted (checkpoint-less) runs: status
`admitted` with no fabricated trace, output, or checkpoint.
## Hypothetically used as follows
EXECUTABLE example (runs in CI as
`tests/examples/test_scheduled_deployment_example.py`; run it with
`uv run pytest -q tests/examples/test_scheduled_deployment_example.py`):
```python
server = build_local_static_workflow_server(root, schedules=True)
await server.api.create_artifact_from_plan(
artifact_id="scheduled_hello", version=1, ...,
)
await server.api.save_deployment({...})
due = datetime.now(UTC) + timedelta(seconds=0.5)
await server.api.schedules.create_schedule(
schedule_id="hello-once",
deployment_id="scheduled_hello.default",
trigger={"kind": "oneshot", "at": due.isoformat()},
)
service = build_scheduler_service(server, SchedulerServiceConfig(...))
await service.start()
# ... the service admits the occurrence and completes the run ...
inspected = await server.api.inspect_run(run_id=run_id)
assert inspected["output"]["result"] == "hello on a schedule"
page = await server.api.schedules.list_schedule_occurrences(
schedule_id="hello-once"
)
await service.stop()
```
ILLUSTRATIVE example (not executed; shows a cron week with an operator
pause — same calls, longer horizons):
```python
# Monday: an hourly report, latest-wins catch-up, at most two at once.
await schedules.create_schedule(
schedule_id="hourly-report",
deployment_id="report.default",
trigger={"kind": "cron", "expression": "0 * * * *", "timezone": "UTC"},
misfire="latest",
overlap="parallel",
max_active_runs=2,
)
# Friday: pause for maintenance; Monday: resume from the next hour.
await schedules.pause_schedule(schedule_id="hourly-report")
await schedules.resume_schedule(schedule_id="hourly-report")
# A bad edit is rejected without touching the running definition:
await schedules.update_schedule(
schedule_id="hourly-report", expected_revision=1, ...
)
```
## Known limitations
- One composition's stores nested inside another live composition's
store subtree (without sharing its identical roots) is unsupported
operator error; shared-store cross layouts are rejected outright.
- Manual runs and resumes bypass scheduler capacity by design;
capacity governs scheduled dispatch only.
- A set `max_steps` budget cannot be cleared back to unset through
update (recreate the schedule for an unbounded budget).
- MCP-backed servers reject scheduler enablement for now.
@@ -16,6 +16,48 @@ Scheduling policies remain settled. Calendar behavior follows the subsequent
approved simplification: croniter owns DST resolution. Findings below record
the missing implementation seams and the completed verification work.
## Completion status (2026-09-09; plan retired to historical/)
All gates and tasks completed on branch `opencode/sched-verify-plan`:
- R0, R1, R2, R3: passed during phased implementation.
- R4 (fault-injection review): passed in three waves — wave 2 bound
ownership to store composition and stabilized recovery failure; wave 3
added one canonical lock identity plus checkpoint coherence and
ordering authority; wave 4 closed the shared-store overlap hole
(sibling-distinct identity, overlapping layouts rejected).
- T01T11: implemented per phase (calendar, expressions, admission,
scheduler core, resume safety, recovery, ownership).
- T12: opt-in server lifecycle with bounded real execution, drain, and
subprocess-tested death paths; independent review passed.
- T13: administration API + JSON-RPC surface + Python client; an
independent review gated it on torn-admin ordering and admin/poll
staleness, both fixed (crash-safe admin ordering, creation watermark,
per-schedule poll freshness) and re-reviewed to pass.
- T14: this completion record; spec updated in place; roadmap and
user docs updated; disposable probes deleted after production-test
equivalence was verified item by item (calendar Part A mirrored in
`test_calendar_adapter.py`; APScheduler rejected-candidate evidence
preserved in the design spec; expression pins mirrored or superseded
by T03/T04 plus core tests; all 31 state-model behaviors mapped to
production tests, adding overlap-independence and store-backed
run-identity pins where no equivalent existed).
Probe retirement map: `probes/deployment_scheduling_verify/` deleted.
`test_calendar_probe.py` Part A is subsumed by
`tests/scheduling/test_calendar_adapter.py`; Part B (APScheduler gap
phantom + fold replay strict xfails) is preserved as narrative evidence
in the design spec, not as runnable tests (it needs an isolated env
with `apscheduler` installed). `test_expression_contract_probe.py` is
subsumed by `tests/scheduling/test_schedule_expressions.py`,
`tests/core/test_input_sources.py`, and core strict-JSON tests, except
the pre-T03 no-seam observation (deliberately superseded) and one
unowned `InputBinding` micro-pin of untouched core code (noted, not
ported). `test_schedule_state_model.py` is subsumed by
`tests/scheduling/` (matrix, coalescing, slots, pause/edit/delete,
restart, downtime, fairness, fault injection, ownership, corrupt
handling) plus the two pins added at retirement.
## Gate 1 — Spec audit against actual code
Three parallel audit sweeps (expression bindings, deployment invocation,
@@ -1,6 +1,9 @@
# Deployment Scheduling
Status: draft for review; not implemented.
Status: implemented (slices T01T14, reviews R0R4 passed); this document
remains the current contract. The implementation plan that built it is
archived at
`docs/historical/superpowers/plans/2026-09-09-deployment-scheduling-implementation-plan.md`.
## Purpose and scope
@@ -247,6 +250,16 @@ anything else under an active attempt is stale and fails closed without
retry. Recovery must distinguish both cases from an interruption that was
merely waiting across server restart.
Implemented checkpoint authority: a checkpoint decides a summary only when
its content is coherent — same run identity, checkpoint id of the form
`{run_id}.{sequence:06d}`, runtime state decodable, and decoded stopped
status equal to the outer reason. A durable failed decision is superseded
only by a present, coherent, strictly newer checkpoint with matching
attempt provenance; a missing or older referenced checkpoint keeps the
decision (noted, history preserved) and never rolls a failed run back to
interrupted. A genuinely newer coherent result still repairs a torn
summary, and repeated recovery is silent and stable.
The process must own the store exclusively before recovery. Enforce scheduler
ownership with a held cross-process lock, not a stale PID file or a lease that
can expire while the old owner still runs. Unsupported locking must reject
@@ -254,6 +267,17 @@ scheduler startup. Other processes mutating/resuming the same store remain
unsupported. This does not upgrade the rest of the file stores to multi-writer
safety or claim exactly-once external effects.
Implemented lock identity: one store composition has exactly one lock file,
at the deepest composition root containing every store root as itself or a
direct child (`canonical_lock_root`). Identical, sibling, and nested roots
covering the same store files contend on that one file; ancestor locks prove
nothing and are rejected, and cross pairs reusing one protected store with a
different partner have no lock identity at all and are rejected before any
write. The acquired identity is frozen at acquisition, so mutating the
handle's root afterwards cannot redirect authority. The server layout points
both stores at the composition root itself (run data at `<root>/runs`,
schedules at `<root>/schedules`, one lock at `<root>/scheduler.lock`).
## Lifecycle, administration, and resource bounds
Expose create/get/list/update/pause/resume/delete and paginated occurrence
@@ -290,6 +314,43 @@ grace period. Record cancellation/failure when possible; abrupt termination
uses startup recovery. Paused/deleted schedule definitions must not prevent
run completion or resume from updating retained occurrence history.
Implemented service and administration surface. The opt-in same-server
scheduler (`SchedulerService`, enabled by `server.scheduler.enabled` or
`wf-rpc-server --enable-scheduler`; local/static servers only) acquires
canonical ownership, recovers without executing, then ticks calendar
polling without blocking on long workflows: each dispatch spawns exactly
one bounded execution task behind the async-completion seam, and the
scheduler's own capacity gate is the execution-slot bound (an executing
run keeps its slot until it stops). Shutdown stops admission, drains
within `drain_grace_s`, leaves unfinished work under its executing mark
for startup recovery to abandon truthfully, and releases ownership last.
A failed startup releases the lock and raises.
Administration (`WorkflowApi` schedules methods, `workflow.schedules.*`
RPC, Python client): `create/get/list/update/pause/resume/delete_schedule`
plus paginated `list_schedule_occurrences`. Creation validates the
trigger, the deployment, the binding shapes, and a sample-occurrence
resolution against the pinned root schema, and starts the consumed
watermark at creation (no pre-creation backfill). Updates are
revision-checked; pause/resume/delete mirror the poll-loop transitions;
all mutating admin ops clear the old revision's unadmitted work and
advance the watermark BEFORE the revision bump or flag flip lands, so a
crash can only leave the op unapplied (retryable), never a new revision
that backfills. Occurrence pages carry the stored history plus a live
held-candidate `pending` synthesis on the first page. Manual runs and
resumes bypass scheduler capacity by design (unchanged API behavior);
scheduler capacity governs scheduled dispatch only, and a scheduled
interrupted run resumed manually reconciles its terminal history through
recovery.
Known limitations: pointing one composition's stores inside another live
composition's store subtree (without sharing its identical roots) is
unsupported operator error; `max_steps: None` means "unpatched" on
update (a set budget cannot be cleared back to unset); a first
occurrence page may carry one row more than `limit` while a candidate is
held; calendar iteration within a tick may use the tick-start source
(trigger edits take effect on the next tick).
## Verification gates
Use injected clocks and controlled executors, not real-time sleeps:
+1
View File
@@ -233,6 +233,7 @@ WorkflowApiSurface
WorkflowArtifactSurface
WorkflowDeploymentSurface
WorkflowRunSurface
WorkflowScheduleSurface
```
The domain services below are the process-local implementation pieces, not the
+11
View File
@@ -109,6 +109,17 @@ small and avoids dumping arbitrary MCP resource payloads.
registry. `--store-root` is for the local/static server path and cannot be
combined with `--mcp-config`.
Opt in to deployment scheduling on a local/static server (MCP-backed
servers reject it for now):
```bash
wf-rpc-server --store-root .wf_store --enable-scheduler
```
or set `server.scheduler.enabled` (plus optional `poll_interval_s`,
`max_concurrent_runs`, `drain_grace_s`) in the neutral config. See
[`deployment scheduling operations`](deployment_scheduling.md).
`admin registry` shows desired persisted source entries. It is separate from
workflow artifacts and deployments, so it can be empty even when the server has
runtime sources and saved workflows.
@@ -1,40 +0,0 @@
# DISPOSABLE VERIFICATION PROBE — NOT PRODUCTION CODE
This directory holds throwaway verification probes for the
deployment-scheduling slice
(`docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md`).
They are evidence-gathering scripts, not a production implementation:
- `test_calendar_probe.py` — calendar-library probe (16 passed, 2 strict
xfailed: Part A pins the croniter contract, Part B keeps APScheduler
rejected-candidate evidence). Requires `croniter==6.2.4`, `tzdata`
(`apscheduler==3.11.0` for Part B only) on Python 3.14.
Run it from an isolated project (NOT the repo env, which does not
depend on croniter; the file `importorskip`s itself elsewhere):
```powershell
$probe = "C:\Users\Admin\AppData\Local\Temp\opencode\sched-cal-probe"
$file = "<worktree>\probes\deployment_scheduling_verify\test_calendar_probe.py"
uv run --project $probe python -m pytest $file -q -p no:cacheprovider `
-o addopts=""
```
- `test_schedule_state_model.py` — pure-stdlib reference model of the
scheduling state machine (25 tests: overlap x misfire, coalescing,
slots, pause, faults, ownership). Runs in the repo env:
```powershell
uv run pytest -q probes/deployment_scheduling_verify/test_schedule_state_model.py
```
- `test_expression_contract_probe.py` — pins the current input-expression
contract for the Phase 1 implementer (8 tests). Runs in the repo env:
```powershell
uv run pytest -q probes/deployment_scheduling_verify/test_expression_contract_probe.py
```
Do not import these probes from `src/`. Do not copy their logic into
production without going through the sequenced implementation plan at
`docs/superpowers/plans/2026-09-09-deployment-scheduling-implementation-plan.md`.
Delete this directory once the slice is implemented.
@@ -1,413 +0,0 @@
# DISPOSABLE CALENDAR-LIBRARY PROBE — NOT PRODUCTION CODE.
# See README.md in this directory. Requires croniter==6.2.4 and tzdata
# (plus apscheduler==3.11.0 for the Part B rejected-candidate evidence)
# on Python 3.14 (isolated env, not repo env).
"""Pin the croniter 6.2.4 calendar contract for deployment scheduling.
Policy: croniter owns calendar calculation, including DST resolution.
The adapter consumes it thinly convert the query instant into the
schedule's named zone, ask for the next/previous occurrence, convert
the result to UTC and applies no correction of its own. Each test
prints OBSERVED lines pinning 6.2.4 behavior. After any
calendar-dependency upgrade a failure IS the finding: re-probe, do not
hand-roll around it.
Part A pins the chosen-library contract (shipping acceptance).
Part B preserves rejected-candidate evidence (NOT acceptance).
Run:
uv run --project <isolated-probe-project> python -m pytest <this file>
-q -p no:cacheprovider -o addopts=""
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
import pytest
# This probe runs ONLY in the isolated calendar env (see README.md).
# Skip — do not error — when collected by a default repo test run.
pytest.importorskip("apscheduler", reason="isolated calendar-probe env only")
pytest.importorskip("croniter", reason="isolated calendar-probe env only")
from apscheduler.triggers.cron import CronTrigger # noqa: E402 (Part B only)
from croniter import ( # noqa: E402
CroniterBadCronError,
CroniterBadDateError,
)
from croniter import (
croniter as Croniter,
)
try: # noqa: E402
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover
from backports.zoneinfo import ZoneInfo # type: ignore[no-redef]
from importlib.metadata import version as _pkg_version # noqa: E402
import apscheduler # noqa: E402
CRONITER_VERSION = _pkg_version("croniter")
print(f"croniter=={CRONITER_VERSION}")
print(f"apscheduler=={apscheduler.__version__} (rejected candidate, Part B only)")
UTC = timezone.utc
HCMC = ZoneInfo("Asia/Ho_Chi_Minh")
NYC = ZoneInfo("America/New_York")
# 2026 DST transitions (US): spring forward 2026-03-08 02:00 -> 03:00,
# fall back 2026-11-01 02:00 -> 01:00.
def utc(*args) -> datetime:
return datetime(*args, tzinfo=UTC)
# ---------------------------------------------------------------------------
# Part A — chosen-library contract (croniter; shipping acceptance)
# ---------------------------------------------------------------------------
def test_versions_pinned():
print(f"OBSERVED croniter version={CRONITER_VERSION}")
print(f"OBSERVED apscheduler.__version__={apscheduler.__version__}")
# Exact pin: a calendar-dependency upgrade must fail here deliberately,
# forcing a re-probe before any pin update.
assert CRONITER_VERSION == "6.2.4"
assert apscheduler.__version__.startswith("3.")
def test_utc_daily_next_is_strictly_increasing_and_unique():
it = Croniter("30 9 * * *", utc(2026, 9, 1, 0, 0, 0))
seen: set[datetime] = set()
prev = None
for _ in range(10):
nxt = it.get_next(datetime)
assert nxt.tzinfo is not None
as_utc = nxt.astimezone(UTC)
assert as_utc not in seen, f"duplicate UTC instant {as_utc}"
seen.add(as_utc)
if prev is not None:
assert as_utc > prev, "not strictly increasing"
print(f"OBSERVED utc-daily next={as_utc.isoformat()}")
prev = as_utc
assert len(seen) == 10
def test_hcmc_daily_converts_and_hourly_counts():
# Asia/Ho_Chi_Minh is UTC+7 with no DST. 09:30 local == 02:30 UTC.
nxt = Croniter("30 9 * * *", datetime(2026, 9, 1, 0, 0, tzinfo=HCMC)).get_next(
datetime
)
as_utc = nxt.astimezone(UTC)
print(f"OBSERVED hcmc next local={nxt.isoformat()} utc={as_utc.isoformat()}")
assert (as_utc.hour, as_utc.minute) == (2, 30)
assert as_utc.date().isoformat() == "2026-09-01"
# No-DST zone: hourly occurrence count over two 48-hour windows
# (March and November, starting off-tick) must be exactly 48 each.
for label, start in (
("march", utc(2026, 3, 7, 17, 30, 0)),
("november", utc(2026, 10, 31, 17, 30, 0)),
):
it = Croniter("0 * * * *", start.astimezone(HCMC))
count = 0
while True:
hit = it.get_next(datetime).astimezone(UTC)
if hit >= start + timedelta(hours=48):
break
count += 1
assert count < 60
print(f"OBSERVED hcmc hourly count window={label} count={count}")
assert count == 48, f"{label}: no-DST zone must yield exactly 48, got {count}"
def test_expression_forms_wildcard_step_list_range():
"""Wildcards, steps, lists, and ranges resolve through plain
get_next on an ordinary day (no DST involved)."""
cases = {
"* * * * *": (datetime(2026, 9, 8, 0, 0, tzinfo=UTC), "2026-09-08T00:01:00"),
"5/15 * * * *": (datetime(2026, 9, 8, 0, 0, tzinfo=UTC), "2026-09-08T00:05:00"),
"0,30 1-2 * * *": (
datetime(2026, 9, 8, 0, 0, tzinfo=UTC),
"2026-09-08T01:00:00",
),
"*/20 1-3 * * *": (
datetime(2026, 9, 8, 1, 50, tzinfo=UTC),
"2026-09-08T02:00:00",
),
}
for expr, (start, want) in cases.items():
nxt = Croniter(expr, start).get_next(datetime)
print(
f"OBSERVED croniter {expr!r} from {start.isoformat()} -> {nxt.isoformat()}"
)
assert nxt.astimezone(UTC).isoformat() == want + "+00:00"
def test_weekday_names_and_numbers_unix():
"""Pin croniter 6.2.4 weekday dialect (re-probe on upgrade). Unix
convention: numeric 0 AND 7 mean Sunday; 1/mon mean Monday."""
monday = datetime(2026, 9, 7, 0, 0, tzinfo=UTC) # a Monday
cases = {
"0 12 * * 0": 6, # Sunday
"0 12 * * 7": 6, # Sunday (alias)
"0 12 * * 1": 0, # Monday
"0 12 * * mon": 0,
"0 12 * * sun": 6,
}
for expr, want_wd in cases.items():
nxt = Croniter(expr, monday).get_next(datetime)
print(
f"OBSERVED croniter {expr!r} -> {nxt.date().isoformat()} weekday={nxt.weekday()}"
)
assert nxt.weekday() == want_wd, f"{expr}: want weekday={want_wd}"
assert Croniter("0 12 * * 0", monday).get_next(datetime) == Croniter(
"0 12 * * sun", monday
).get_next(datetime)
def test_dom_dow_day_or_true_is_selected():
"""Day-of-month/day-of-week uses croniter's standard day_or=True
(Unix OR). The alternative (AND) is pinned for reference only."""
start = datetime(2026, 9, 1, 0, 0, tzinfo=UTC)
it_or = Croniter("0 12 13 * fri", start, day_or=True)
hits_or = [it_or.get_next(datetime).date().isoformat() for _ in range(4)]
print(f"OBSERVED croniter day_or=True hits={hits_or}")
# Fridays plus the 13th (a Sunday): classic Unix OR.
assert hits_or == ["2026-09-04", "2026-09-11", "2026-09-13", "2026-09-18"]
it_and = Croniter("0 12 13 * fri", start, day_or=False)
first_and = it_and.get_next(datetime).date()
print(
f"OBSERVED croniter day_or=False first={first_and.isoformat()} (not selected)"
)
assert (first_and.day, first_and.weekday()) == (13, 4)
def test_zone_conversion_is_the_callers_job():
"""croniter iterates in the tz of the supplied datetime and performs
no conversion: a UTC start yields UTC results. The adapter converts
now into the schedule zone before querying and back to UTC after."""
utc_start = Croniter(
"30 2 * * *", datetime(2026, 3, 7, 12, 0, tzinfo=UTC)
).get_next(datetime)
print(
f"OBSERVED croniter utc-start gap next={utc_start.isoformat()} (no zone conversion)"
)
assert utc_start.tzinfo is UTC
hcmc_start = Croniter(
"30 9 * * *", datetime(2026, 9, 1, 0, 0, tzinfo=HCMC)
).get_next(datetime)
print(f"OBSERVED croniter hcmc-start next={hcmc_start.isoformat()}")
assert hcmc_start.utcoffset() == timedelta(hours=7)
def test_gap_nonexistent_wall_times_resolve_forward():
"""Observed 6.2.4 gap behavior: daily 02:30 on 2026-03-08 (a wall
time that never existed in America/New_York) resolves to 03:00-04:00
the same day. That resolution IS the occurrence there is no
separate validity concept and no day is skipped over."""
first = Croniter("30 2 * * *", datetime(2026, 3, 7, 12, 0, tzinfo=NYC)).get_next(
datetime
)
print(f"OBSERVED croniter gap-day daily-0230 resolves={first.isoformat()}")
assert first.isoformat() == "2026-03-08T03:00:00-04:00"
following = Croniter("30 2 * * *", first).get_next(datetime)
print(f"OBSERVED croniter gap following={following.isoformat()}")
assert following.isoformat() == "2026-03-09T02:30:00-04:00"
def test_gap_per_minute_stream_jumps_forward():
"""Per-minute iteration across the spring gap jumps 01:59 EST
straight to 03:00 EDT: no 02:xx wall times are emitted, UTC is
strictly increasing and unique."""
it = Croniter("* * * * *", datetime(2026, 3, 8, 0, 0, tzinfo=NYC))
seq = [it.get_next(datetime) for _ in range(300)]
us = [d.astimezone(UTC) for d in seq]
assert all(b > a for a, b in zip(us, us[1:]))
assert len(set(us)) == len(us)
gap_day = [d for d in seq if d.date().isoformat() == "2026-03-08"]
assert all(d.hour != 2 for d in gap_day), "no 02:xx wall times emitted"
assert "2026-03-08T01:59:00-05:00" in (d.isoformat() for d in gap_day)
assert "2026-03-08T03:00:00-04:00" in (d.isoformat() for d in gap_day)
print(
"OBSERVED per-minute gap jump 01:59-05:00 -> 03:00-04:00, "
f"{len(gap_day)} gap-day hits"
)
def test_gap_backward_queries_return_library_resolution():
"""Backward queries resolve the same way: get_prev on the gap day
returns the forward-resolved 03:00-04:00, not a skipped-over day.
Latest-missed on a gap day is that resolved instant."""
cases = {
"2026-03-08T12:00": "2026-03-08T03:00:00-04:00",
"2026-03-09T00:00": "2026-03-08T03:00:00-04:00",
"2026-03-07T12:00": "2026-03-07T02:30:00-05:00",
"2026-03-09T12:00": "2026-03-09T02:30:00-04:00",
}
for start_iso, want in cases.items():
start = datetime.fromisoformat(start_iso).replace(tzinfo=NYC)
got = Croniter("30 2 * * *", start).get_prev(datetime)
print(f"OBSERVED croniter gap get_prev from {start_iso} -> {got.isoformat()}")
assert got.isoformat() == want
def test_fold_repeated_times_are_distinct_utc():
"""Both 01:30s on 2026-11-01 occur as distinct UTC instants
(05:30Z EDT, then 06:30Z EST)."""
it = Croniter("30 1 * * *", datetime(2026, 10, 31, 12, 0, tzinfo=NYC))
first = it.get_next(datetime)
second = it.get_next(datetime)
print(
f"OBSERVED croniter fold first={first.isoformat()} second={second.isoformat()}"
)
assert first.astimezone(UTC) == utc(2026, 11, 1, 5, 30)
assert second.astimezone(UTC) == utc(2026, 11, 1, 6, 30)
def test_fold_per_minute_unique_increasing():
"""Per-minute iteration across the fall fold emits both fold hours
with strictly increasing unique UTC instants."""
it = Croniter("* * * * *", datetime(2026, 10, 31, 20, 0, tzinfo=NYC))
seq = [it.get_next(datetime) for _ in range(560)]
us = [d.astimezone(UTC) for d in seq]
print(f"OBSERVED croniter fold-span count={len(us)} last={us[-1].isoformat()}")
assert all(b > a for a, b in zip(us, us[1:])), "must be strictly increasing"
assert len(set(us)) == len(us), "UTC identities must be unique"
iso = {d.isoformat() for d in us}
assert "2026-11-01T05:30:00+00:00" in iso, "first 01:30 (EDT) must occur"
assert "2026-11-01T06:30:00+00:00" in iso, "second 01:30 (EST) must occur"
def test_latest_missed_after_years_of_downtime():
"""Per-minute schedule, ~3 years of downtime: get_prev answers the
latest missed occurrence directly no enumeration of missed years."""
now = utc(2026, 9, 8, 12, 0, 0)
t0 = time.perf_counter()
latest = Croniter("* * * * *", now).get_prev(datetime)
elapsed = time.perf_counter() - t0
print(
f"OBSERVED croniter get_prev({now.isoformat()})={latest.isoformat()} "
f"elapsed={elapsed:.4f}s"
)
assert latest.tzinfo is not None, "get_prev must preserve tz-awareness"
assert elapsed < 5, "latest-missed lookup must be bounded"
assert latest <= now
assert (now - latest) < timedelta(minutes=2)
now_nyc = datetime(2026, 9, 8, 12, 0, tzinfo=NYC)
latest_nyc = Croniter("* * * * *", now_nyc).get_prev(datetime)
print(
f"OBSERVED croniter nyc get_prev now={now_nyc.isoformat()} "
f"latest={latest_nyc.isoformat()}"
)
assert latest_nyc.tzinfo is not None
assert latest_nyc <= now_nyc
assert (now_nyc - latest_nyc) < timedelta(minutes=2)
assert latest_nyc.astimezone(UTC).isoformat() == "2026-09-08T15:59:00+00:00"
def test_iteration_is_exclusive_both_directions():
"""Both get_next and get_prev are exclusive of their start: a query
from exactly a due instant returns the neighboring occurrence, not
the instant itself. Adapter consequence (T01, not enforced here):
query forward from the last-consumed instant and compare catch-up
results against the same watermark, so a due occurrence is admitted
exactly once neither missed by exclusivity nor admitted twice."""
due = utc(2026, 9, 8, 13, 0, 0)
fwd = Croniter("0 13 * * *", due).get_next(datetime)
back = Croniter("0 13 * * *", due).get_prev(datetime)
print(f"OBSERVED exclusive get_next(due)={fwd.isoformat()}")
print(f"OBSERVED exclusive get_prev(due)={back.isoformat()}")
assert fwd.astimezone(UTC) == utc(2026, 9, 9, 13, 0, 0)
assert (back.year, back.month, back.day, back.hour, back.minute) == (
2026,
9,
7,
13,
0,
)
just_before = Croniter("0 13 * * *", due - timedelta(seconds=1)).get_next(datetime)
assert just_before.astimezone(UTC) == due, "a tick just before due admits it"
def test_impossible_schedule_raises_promptly():
"""February 30th never occurs: the library raises its documented
exhaustion error promptly instead of searching forever. The adapter
maps this to exhausted exhaustion must never look like progress."""
start = time.perf_counter()
with pytest.raises(CroniterBadDateError):
Croniter("0 12 30 2 *", utc(2026, 1, 1)).get_next(datetime)
elapsed = time.perf_counter() - start
print(
f"OBSERVED impossible-schedule raised CroniterBadDateError elapsed={elapsed:.3f}s"
)
assert elapsed < 5, f"search must be bounded, took {elapsed:.1f}s"
def test_bad_expressions_rejected_naive_passes_through():
"""Malformed expressions fail at construction with a documented
error. Naive datetimes are NOT rejected by the library they pass
straight through so the adapter rejects naive itself per spec."""
with pytest.raises(CroniterBadCronError):
Croniter("nonsense", utc(2026, 1, 1))
print("OBSERVED malformed expression rejected with CroniterBadCronError")
naive = Croniter("* * * * *", datetime(2026, 9, 8, 12, 0)).get_next(datetime)
print(f"OBSERVED naive start passes through tzinfo={naive.tzinfo}")
assert naive.tzinfo is None
# ---------------------------------------------------------------------------
# Part B — rejected-candidate evidence (APScheduler; NOT acceptance)
# ---------------------------------------------------------------------------
@pytest.mark.xfail(
strict=True,
reason="REJECTED CANDIDATE: APScheduler 3.11.0 fabricates a phantom "
"02:30-05:00 for the DST gap. Kept as evidence for the rejection; "
"the chosen library's gap behavior is pinned in Part A. Strict so "
"re-opening the candidacy fails loudly. See implementation plan.",
)
def test_rejected_candidate_gap_phantom():
# 02:30 does not exist in America/New_York on 2026-03-08. The
# disqualifier is the phantom itself: whatever the library returns
# must not be a nonexistent wall time.
trig = CronTrigger(hour=2, minute=30, second=0, timezone="America/New_York")
nxt = trig.get_next_fire_time(None, utc(2026, 3, 7, 12, 0, 0))
assert nxt is not None
local = nxt.astimezone(NYC)
print(f"OBSERVED apscheduler dst-gap next local={local.isoformat()}")
assert not (
(local.year, local.month, local.day) == (2026, 3, 8) and local.hour == 2
), f"library must not fabricate a nonexistent wall time, got {local.isoformat()}"
@pytest.mark.xfail(
strict=True,
reason="REJECTED CANDIDATE: APScheduler 3.11.0 replays 05:01Z-06:00Z "
"(~59 past minutes) after the fall fold, duplicating UTC occurrence "
"identities. Kept as evidence for the rejection; the chosen "
"library's fold behavior is pinned in Part A. See implementation plan.",
)
def test_rejected_candidate_fold_replay():
trig = CronTrigger(minute="*", second=0, timezone="America/New_York")
now = utc(2026, 11, 1, 0, 0, 0)
prev = None
seen: set[str] = set()
last: datetime | None = None
for _ in range(450): # spans past 07:00Z: covers BOTH fold hours
nxt = trig.get_next_fire_time(prev, now)
assert nxt is not None
as_utc = nxt.astimezone(UTC)
ident = f"sched-1|{as_utc.isoformat()}"
assert ident not in seen, f"duplicate occurrence identity {ident}"
seen.add(ident)
if last is not None:
assert as_utc > last, "occurrences must be strictly increasing"
last = as_utc
prev, now = nxt, nxt
print(f"OBSERVED fold-span count={len(seen)} unique, last={last.isoformat()}")
@@ -1,138 +0,0 @@
# DISPOSABLE EXPRESSION-CONTRACT PROBE — NOT PRODUCTION CODE.
# See README.md in this directory. Runs in the repo env:
# uv run pytest -q probes/deployment_scheduling_verify/test_expression_contract_probe.py
"""Pin the CURRENT input-expression contract that the scheduling slice must
reuse (spec: Input authoring and serialization).
These tests document what exists today for the Phase 1 implementer: the
closed 4-kind union, budget enforcement point, strict-JSON literals,
target-conflict detection, closed GraphSourcePath roots, and the single
hardcoded graph-context evaluator. A schedule occurrence source must plug
into this traversal (T03/T04 of the implementation plan), not copy it.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from wf_core.local_paths import has_overlapping_paths
from wf_core.models.input_bindings import (
MAX_INPUT_EXPRESSION_DEPTH,
MAX_INPUT_EXPRESSION_NODES,
ArrayExpression,
InputExpressionBinding,
LiteralExpression,
ObjectExpression,
PathExpression,
validate_input_expression_limits,
)
from wf_core.models.json_values import validate_strict_json_value
from wf_core.paths import GraphSourcePath
from wf_core.runtime.input_bindings import (
resolve_input_expression,
resolve_step_input_bindings,
)
def test_expression_union_is_closed_to_four_kinds():
assert LiteralExpression(kind="literal", value=1).kind == "literal"
assert PathExpression(kind="path", path="input.a").kind == "path"
assert ArrayExpression(kind="array", items=[]).kind == "array"
assert ObjectExpression(kind="object", fields={}).kind == "object"
with pytest.raises(ValidationError):
InputExpressionBinding(
target="x", expression={"kind": "occurrence", "field": "scheduled_at"}
) # type: ignore[dict-item]
print("OBSERVED occurrence kind rejected: union closed to 4 kinds")
def test_budget_constants_and_validator_entry_point():
assert (MAX_INPUT_EXPRESSION_DEPTH, MAX_INPUT_EXPRESSION_NODES) == (64, 1024)
deep: dict = {"kind": "literal", "value": 0}
for _ in range(MAX_INPUT_EXPRESSION_DEPTH + 5):
deep = {"kind": "array", "items": [deep]}
with pytest.raises(ValueError, match="limit exceeded"):
validate_input_expression_limits(deep) # type: ignore[arg-type]
print("OBSERVED depth budget enforced by validate_input_expression_limits")
def test_strict_json_rejects_non_finite_and_non_string_keys():
with pytest.raises(ValueError):
validate_strict_json_value(float("inf"))
with pytest.raises(ValueError):
validate_strict_json_value({1: "x"})
assert validate_strict_json_value({"a": [1, None, "x"]}) == {"a": [1, None, "x"]}
print("OBSERVED strict-JSON validator rejects inf and non-string keys")
def test_target_conflicts_detected_on_local_paths():
assert has_overlapping_paths(["a.b", "a.b.c"])
assert not has_overlapping_paths(["a.b", "a.c"])
print("OBSERVED overlapping local-path targets detected")
def test_graph_source_roots_closed_to_input_state_context():
assert GraphSourcePath.parse("input.a").root == "input"
assert GraphSourcePath.parse("state.a").root == "state"
assert GraphSourcePath.parse("context.a").root == "context"
with pytest.raises(ValueError):
GraphSourcePath.parse("occurrence.scheduled_at")
print("OBSERVED occurrence root rejected: GraphSourcePath closed")
def test_runtime_resolver_composes_literal_object_array_and_paths():
expr = ObjectExpression(
kind="object",
fields={
"team": LiteralExpression(kind="literal", value="eng"),
"tags": ArrayExpression(
kind="array",
items=[LiteralExpression(kind="literal", value="a")],
),
"req": PathExpression(kind="path", path="input.request_id"),
},
)
resolved = resolve_input_expression(
expr,
state={},
workflow_input={"request_id": "r1"},
context={},
label="probe",
location="$",
)
assert resolved == {"team": "eng", "tags": ["a"], "req": "r1"}
print(f"OBSERVED composed resolution -> {resolved}")
def test_runtime_resolver_takes_only_graph_context_mappings():
# There is no source-resolver seam: the only injection point is the
# concrete state/workflow_input/context mappings (faking occurrence
# values through context is exactly what the spec forbids).
import inspect
sig = inspect.signature(resolve_input_expression)
assert list(sig.parameters) == [
"expression",
"state",
"workflow_input",
"context",
"label",
"location",
], f"no resolver parameter exists: {list(sig.parameters)}"
assert "resolver" not in inspect.signature(resolve_step_input_bindings).parameters
print("OBSERVED resolver signatures are concrete graph mappings; no seam")
def test_node_input_binding_rejects_expression_kind_at_top_level():
# StepInputBinding allows expressions, but plain InputBinding
# (deployment-level inputs) does not carry them — resolved data only.
from pydantic import TypeAdapter
from wf_core.models.input_bindings import InputBinding
with pytest.raises(ValidationError):
TypeAdapter(InputBinding).validate_python(
{"target": "x", "expression": {"kind": "literal", "value": 1}}
)
print("OBSERVED top-level InputBinding carries no expressions")
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,8 +1,9 @@
"""Poll loop: overlap, misfire, candidates, fairness, capacity (T08).
Mirrors the reference state model (probes/deployment_scheduling_verify/
test_schedule_state_model.py) against real file stores. Calendar iteration
uses the canonical :class:`wf_scheduling.calendar.OccurrenceSource`
Implements the scheduling state rules (first probed as a reference model,
retired to docs/historical now that tests/scheduling/ pins them) against
real file stores. Calendar iteration uses the canonical
:class:`wf_scheduling.calendar.OccurrenceSource`
(``next_after``/``prev_before`` only, never enumeration); latest-missed
catch-up is one bounded ``prev_before`` query (F1). Overlap decisions
precede capacity checks; terminal skips never reappear; ``latest`` retains
+17
View File
@@ -69,6 +69,7 @@ def serve(
server = None
workflow_config: WorkflowConfigFile | None = None
mcp_backed = mcp_config is not None
if mcp_config is not None:
server = build_workflow_server_from_legacy_mcp_config(mcp_config)
@@ -118,6 +119,22 @@ def serve(
server = build_local_static_workflow_server(resolved_store_root, drafts=True)
sched_config = server_scheduler_config(workflow_config, enable_scheduler)
if sched_config is not None:
config_mcp_sources = (
workflow_config is not None
and any(
getattr(source, "kind", None) == "mcp"
for source in workflow_config.server.sources
)
)
if mcp_backed or config_mcp_sources:
# The scheduler is verified over local/static servers only:
# refuse to run it over an MCP-backed runtime instead of
# operating it untested.
raise typer.BadParameter(
"--enable-scheduler requires a local/static server; "
"MCP-backed servers are not supported yet"
)
rpc_app = create_rpc_app(
server,
rpc_path=resolved_rpc_path,
+9
View File
@@ -117,3 +117,12 @@ def test_file_run_store_rejects_unsafe_run_id(tmp_path) -> None:
def test_workflow_run_record_validates_latest_checkpoint_id() -> None:
with pytest.raises(ValidationError):
run_record("run_123", "../outside")
def test_allocate_run_id_survives_fresh_instances_without_reuse(tmp_path) -> None:
first = FileRunStore(tmp_path)
assert first.allocate_run_id() == "run-000001"
assert first.allocate_run_id() == "run-000002"
# A fresh instance (restart boundary) must not reuse identities.
second = FileRunStore(tmp_path)
assert second.allocate_run_id() == "run-000003"
@@ -0,0 +1,136 @@
"""Executable scheduling example: one deployment run on a timer.
This is the runnable companion to
``docs/deployment_scheduling.md`` ("hypothetically used as follows"):
a local server plus the opt-in scheduler admit and complete one
one-shot scheduled run of a constant workflow, then show its occurrence
history. Run it with::
uv run pytest -q tests/examples/test_scheduled_deployment_example.py
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from typing import Any
from wf_api.models import RawWorkflowPlan
from wf_core import END
from wf_server import build_local_static_workflow_server
from wf_server.scheduling import build_scheduler_service
from wf_scheduling.lifecycle import SchedulerServiceConfig
from wf_scheduling.store import FileScheduleStore
def _constant_plan() -> RawWorkflowPlan:
return RawWorkflowPlan.model_validate(
{
"name": "scheduled_hello",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
},
"output_schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
"required": ["result"],
},
"outcomes": ["ok"],
"start": "constant",
"nodes": [
{
"id": "constant",
"type": "node",
"node": "wf.std.constant",
"input": [
{
"value": "hello on a schedule",
"target": {"root": "local", "parts": ["value"]},
}
],
"output": [
{
"source": {"root": "local", "parts": ["value"]},
"target": {"root": "state", "parts": ["result"]},
}
],
}
],
"edges": [{"from": "constant", "outcome": "ok", "to": END}],
"output": [
{
"path": {"root": "state", "parts": ["result"]},
"target": {"root": "local", "parts": ["result"]},
}
],
}
)
async def _wait_for(condition: Any, timeout: float = 30.0) -> None:
async with asyncio.timeout(timeout):
while not condition():
await asyncio.sleep(0.02)
async def test_scheduled_deployment_completes_on_a_timer(tmp_path: Any) -> None:
root = tmp_path / "store"
server = build_local_static_workflow_server(root, schedules=True)
await server.api.create_artifact_from_plan(
artifact_id="scheduled_hello",
version=1,
title="Scheduled Hello",
plan=_constant_plan(),
outcomes=["ok"],
source_bindings={},
)
await server.api.save_deployment(
{
"id": "scheduled_hello.default",
"artifact_id": "scheduled_hello",
"artifact_version": 1,
"bindings": {},
}
)
due = datetime.now(UTC) + timedelta(seconds=0.5)
created = await server.api.schedules.create_schedule(
schedule_id="hello-once",
deployment_id="scheduled_hello.default",
trigger={"kind": "oneshot", "at": due.isoformat()},
)
assert created["revision"] == 1
service = build_scheduler_service(
server,
SchedulerServiceConfig(poll_interval_s=0.05, capacity=2),
)
try:
await service.start()
def _completed() -> bool:
runs = list(server.stores.run_store.list_runs())
return any(run.status.value == "completed" for run in runs)
await _wait_for(_completed)
run_id = next(
run.id
for run in server.stores.run_store.list_runs()
if run.status.value == "completed"
)
inspected = await server.api.inspect_run(run_id=run_id)
assert inspected["status"] == "completed"
assert (inspected["output"] or {})["result"] == "hello on a schedule"
page = await server.api.schedules.list_schedule_occurrences(
schedule_id="hello-once"
)
kinds = [row["kind"] for row in page["occurrences"]]
assert "admitted" in kinds
assert "completed" in kinds
finally:
await service.stop()
# The schedule definition survives its run; history is retained.
assert FileScheduleStore(root).get_schedule("hello-once").exhausted is True
+31
View File
@@ -353,3 +353,34 @@ def test_poll_one_uses_fresh_definition_after_edit(tmp_path: Path) -> None:
assert admission.resolved_input["team"] == "new"
assert admission.schedule_revision == 2
sched.ownership.release()
def test_manual_and_other_schedule_runs_are_overlap_independent(
tmp_path: Path,
) -> None:
from wf_api.run_lifecycle import (
materialize_admitted_view,
persist_admission,
)
sched, store, runs, sources = _harness(tmp_path, script={"*": "hang"})
t0 = ts(2026, 9, 8, 12, 0)
# An active run of another schedule occupies only its own slot.
_add(sched, store, sources, "b", OneShotSource(t0), t0 - timedelta(hours=1))
assert sched.poll(t0)["b"].startswith("admit:run-")
# A manual run (no schedule owner) participates in no overlap check.
manual_id = runs.allocate_run_id()
manual = persist_admission(
store=runs,
run_id=manual_id,
environment=fixture_environment(object()),
resolved_input={},
max_steps=None,
)
materialize_admitted_view(store=runs, admission=manual)
# A due one-shot admits despite both unrelated active runs.
_add(sched, store, sources, "a", OneShotSource(t0), t0 - timedelta(hours=1))
result = sched.poll(t0 + timedelta(seconds=1))
assert result["a"].startswith("admit:run-")
assert result["b"] == "exhausted"
sched.ownership.release()
+78
View File
@@ -565,3 +565,81 @@ def test_rpc_server_cli_flag_overrides_disabled_config_scheduler(
assert result.exit_code == 0, result.output
assert captured["lifespan"] is not None
def test_rpc_server_cli_enable_scheduler_rejects_mcp_backed_server(
monkeypatch, tmp_path
) -> None:
config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text(
json.dumps({"store_root": str(tmp_path / "store"), "connections": []}),
encoding="utf-8",
)
def fake_build_mcp_server(path):
return object()
monkeypatch.setattr(
"wf_server.cli.build_workflow_server_from_legacy_mcp_config",
fake_build_mcp_server,
)
result = CliRunner().invoke(
app,
[
"--mcp-config",
str(config_path),
"--enable-scheduler",
],
)
assert result.exit_code != 0
assert "requires a local/static server" in result.output
def test_rpc_server_cli_config_mcp_sources_reject_scheduler(
monkeypatch, tmp_path
) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"server": {
"store": {"kind": "filesystem", "root": ".wf_store"},
"sources": [
{
"kind": "mcp",
"id": "everything.default",
"provider": "everything",
"account": "default",
"transport": {
"kind": "stdio",
"command": "uvx",
"args": ["mcp-server-everything"],
},
}
],
"scheduler": {"enabled": True},
},
}
),
encoding="utf-8",
)
def fake_build_server(config, *, drafts=False):
return object()
def fake_create_rpc_app(server, *, rpc_path="/rpc", drafts=False, lifespan=None):
return object()
monkeypatch.setattr(
"wf_server.cli.build_workflow_server_from_workflow_config",
fake_build_server,
)
monkeypatch.setattr("wf_server.cli.create_rpc_app", fake_create_rpc_app)
result = CliRunner().invoke(app, ["--config", str(config_path)])
assert result.exit_code != 0
assert "requires a local/static server" in result.output