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.
|
deployments, dependency compatibility, and interrupt limitations.
|
||||||
- [`durable_run_operations.md`](durable_run_operations.md): `run_deployment`,
|
- [`durable_run_operations.md`](durable_run_operations.md): `run_deployment`,
|
||||||
`inspect_run`, bounded trace reads, and `resume_run` behavior.
|
`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
|
- [`workflow_drafts.md`](workflow_drafts.md): LLM/human draft authoring format
|
||||||
above raw workflow plans.
|
above raw workflow plans.
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ The active sequence can assume these foundations:
|
|||||||
- Python client reconstruction of capabilities, artifacts, deployments, and
|
- Python client reconstruction of capabilities, artifacts, deployments, and
|
||||||
runs through the API
|
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
|
The current foreach return contract is
|
||||||
[`foreach back-edge design`](superpowers/specs/2026-09-04-foreach-back-edge-design.md).
|
[`foreach back-edge design`](superpowers/specs/2026-09-04-foreach-back-edge-design.md).
|
||||||
The current context contract is
|
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
|
approved simplification: croniter owns DST resolution. Findings below record
|
||||||
the missing implementation seams and the completed verification work.
|
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
|
## Gate 1 — Spec audit against actual code
|
||||||
|
|
||||||
Three parallel audit sweeps (expression bindings, deployment invocation,
|
Three parallel audit sweeps (expression bindings, deployment invocation,
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# Deployment Scheduling
|
# 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
|
## 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
|
retry. Recovery must distinguish both cases from an interruption that was
|
||||||
merely waiting across server restart.
|
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
|
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
|
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
|
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
|
unsupported. This does not upgrade the rest of the file stores to multi-writer
|
||||||
safety or claim exactly-once external effects.
|
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
|
## Lifecycle, administration, and resource bounds
|
||||||
|
|
||||||
Expose create/get/list/update/pause/resume/delete and paginated occurrence
|
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
|
uses startup recovery. Paused/deleted schedule definitions must not prevent
|
||||||
run completion or resume from updating retained occurrence history.
|
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
|
## Verification gates
|
||||||
|
|
||||||
Use injected clocks and controlled executors, not real-time sleeps:
|
Use injected clocks and controlled executors, not real-time sleeps:
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ WorkflowApiSurface
|
|||||||
WorkflowArtifactSurface
|
WorkflowArtifactSurface
|
||||||
WorkflowDeploymentSurface
|
WorkflowDeploymentSurface
|
||||||
WorkflowRunSurface
|
WorkflowRunSurface
|
||||||
|
WorkflowScheduleSurface
|
||||||
```
|
```
|
||||||
|
|
||||||
The domain services below are the process-local implementation pieces, not the
|
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
|
registry. `--store-root` is for the local/static server path and cannot be
|
||||||
combined with `--mcp-config`.
|
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
|
`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
|
workflow artifacts and deployments, so it can be empty even when the server has
|
||||||
runtime sources and saved workflows.
|
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).
|
"""Poll loop: overlap, misfire, candidates, fairness, capacity (T08).
|
||||||
|
|
||||||
Mirrors the reference state model (probes/deployment_scheduling_verify/
|
Implements the scheduling state rules (first probed as a reference model,
|
||||||
test_schedule_state_model.py) against real file stores. Calendar iteration
|
retired to docs/historical now that tests/scheduling/ pins them) against
|
||||||
uses the canonical :class:`wf_scheduling.calendar.OccurrenceSource`
|
real file stores. Calendar iteration uses the canonical
|
||||||
|
:class:`wf_scheduling.calendar.OccurrenceSource`
|
||||||
(``next_after``/``prev_before`` only, never enumeration); latest-missed
|
(``next_after``/``prev_before`` only, never enumeration); latest-missed
|
||||||
catch-up is one bounded ``prev_before`` query (F1). Overlap decisions
|
catch-up is one bounded ``prev_before`` query (F1). Overlap decisions
|
||||||
precede capacity checks; terminal skips never reappear; ``latest`` retains
|
precede capacity checks; terminal skips never reappear; ``latest`` retains
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ def serve(
|
|||||||
|
|
||||||
server = None
|
server = None
|
||||||
workflow_config: WorkflowConfigFile | None = None
|
workflow_config: WorkflowConfigFile | None = None
|
||||||
|
mcp_backed = mcp_config is not None
|
||||||
if mcp_config is not None:
|
if mcp_config is not None:
|
||||||
server = build_workflow_server_from_legacy_mcp_config(mcp_config)
|
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)
|
server = build_local_static_workflow_server(resolved_store_root, drafts=True)
|
||||||
|
|
||||||
sched_config = server_scheduler_config(workflow_config, enable_scheduler)
|
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(
|
rpc_app = create_rpc_app(
|
||||||
server,
|
server,
|
||||||
rpc_path=resolved_rpc_path,
|
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:
|
def test_workflow_run_record_validates_latest_checkpoint_id() -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
run_record("run_123", "../outside")
|
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.resolved_input["team"] == "new"
|
||||||
assert admission.schedule_revision == 2
|
assert admission.schedule_revision == 2
|
||||||
sched.ownership.release()
|
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 result.exit_code == 0, result.output
|
||||||
assert captured["lifespan"] is not None
|
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