sched: docs, probe retirement, MCP guard, coverage pins, executable example (T14)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 1–100,
|
||||
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.
|
||||
+42
@@ -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).
|
||||
- T01–T11: 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 T01–T14, reviews R0–R4 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:
|
||||
|
||||
@@ -233,6 +233,7 @@ WorkflowApiSurface
|
||||
WorkflowArtifactSurface
|
||||
WorkflowDeploymentSurface
|
||||
WorkflowRunSurface
|
||||
WorkflowScheduleSurface
|
||||
```
|
||||
|
||||
The domain services below are the process-local implementation pieces, not the
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user