33 KiB
Deployment Scheduling: Verification Report And Sequenced Implementation Plan
Status: verification complete; planning only. No production scheduling code exists on this branch. No blocking product decisions remain (the calendar policy is decided: croniter owns DST resolution — see Gate 2; the former custom skip/filter approach is retired, not pending).
- Branch:
opencode/sched-verify-plan - Worktree:
C:\Users\Admin\Documents\lda.chat\lda-workflow-as-struct-sched-verify - Spec under review:
docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md(updated in this worktree; the original untracked main-worktree copy is unchanged and is no longer identical) - Probes (disposable, not production):
probes/deployment_scheduling_verify/
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.
Gate 1 — Spec audit against actual code
Three parallel audit sweeps (expression bindings, deployment invocation, runs/resume/server/stores) agree. Headline: the spec's description of the current code is accurate, and every scheduling seam it names is genuinely missing. In particular: the existing run API supports stopped-run persistence only; durable admission does not exist.
1.1 Input authoring and serialization (spec lines 164-207)
- Reuse literal/object/array/target-path/strict-JSON/budget semantics:
src/wf_core/models/input_bindings.py:11-12(budget consts),:15-35InputPathBinding,:38-56InputValueBinding+ strict JSON,:59-97literal/path/array/object expressions,src/wf_core/models/json_values.py:9-31,src/wf_core/local_paths.py:38-74overlap checks. Supported as vocabulary. - Closed 4-kind expression union; no date/format/arithmetic machinery:
input_bindings.py:100-103discriminator union; only graph-path resolution insrc/wf_core/runtime/input_bindings.py:23-114. Supported. - Shared composition traversal behind a typed source-resolver seam:
resolve_input_expression()takes concretestate/workflow_input/contextmappings;grep SourceResolverinsrc/has no hits; two more hardcoded recursions exist (src/wf_core/validation/steps.py:242-284,src/wf_api/input_expressions.py:99-253). Missing seam — schedule evaluation can only copy the evaluator or fakecontext. - Typed occurrence reference; graph refs invalid in schedules: no
occurrence/scheduled_at/schedule_idmodel anywhere insrc/. Missing (expected; this is the work). - Do not extend
GraphSourcePathwith schedule-only roots:src/wf_core/paths.py:17(GraphRoot= input/state/context),:232-265closed roots/parse/factories,:423-446resolution. Supported as constraint; any occurrence-as-root edit contradicts it. - Expression budget enforced on the schedule path: budget checked once
at
InputExpressionBinding.check_limits(input_bindings.py:176-183); the runtime resolver has no limit check. Missing on any path that bypassesStepInputBinding.
1.2 Deployment invocation and pinned environments (spec 22-24, 198-206)
- Deployment-run API receives resolved input data:
WorkflowRunApi.run_deployment(deployment_id, workflow_input: dict, ...)(src/wf_api/runs.py:77-84); same shape inservice.py:1045,surface.py:514,protocols.py:118,transport/models.py:434,wf_client/deployments.py:182. Supported — the scheduler needs only a binding→resolved composer, no API change. - Pinned artifact tree captured at run start:
PinnedRunEnvironment{deployment, root_artifact, child_artifacts}(src/wf_artifacts/runs/models.py:51),resolve_saved_subgraph_tree(src/wf_api/saved_subgraphs.py:65), resume reusesrecord.environmentwithout re-reading the deployment (runs.py:192-201). Supported for resume; missing for admission (frozen only in memory, persisted only after stop). - Deployment revision for edit-race detection:
WorkflowDeployment(src/wf_artifacts/models.py:215) has NO revision;save_deploymentoverwrites (src/wf_artifacts/store.py:113); onlyDraftWorkspacehas revisions. Missing — "captured invocation stays unchanged" needs a deployment revision or content hash. - Durable admission (preassigned run id, persist-before-dispatch,
idempotent reconcile):
runs.py:101-110runsraw_plan_from_artifact→ full in-memoryrun_workflow_from_plan→persist_stopped_run; id allocated post-execution inrun_lifecycle.py:63. Missing entirely (see 1.3).
1.3 Durable admission, resume, server, stores (spec 184-260)
wf_api/runs.pyexecutes before persisting:runs.py:102-110(execute, thenpersist_stopped_run). Supported.- Run models permit only stopped summaries + required checkpoint id:
StoredRunStatus= interrupted/completed/failed (runs/models.py:27-32);WorkflowRunRecord.latest_checkpoint_idrequired (:70);persist_stopped_runrejects active states (run_lifecycle.py:46-61);restore/loadassume a checkpoint exists (:101-134). Supported — inspection of an admitted/in-flight run is impossible today. - Resume marks the active attempt durably before re-executing:
resume_run(src/wf_api/runs.py:194-208): load → validate →resume_workflow_from_plan→ persist. No store write between load and execute (the RUNNING flip insrc/wf_core/runtime/preparation.py:87is in-memory only). Missing — crash-during-resume re-presents the old interrupted checkpoint as safe to retry. persist_stopped_runis a transaction: two separate writes,save_checkpointthensave_run(run_lifecycle.py:96-97); each file atomic via tmp+rename (runs/store.py:90-94). Supported as single-file atomicity; cross-file atomicity missing (recovery authority needed).- Server has an enable flag, poll loop, capacity, drain:
WorkflowServeris a frozen dataclass with no lifecycle methods (wf_server/context.py:279-341);cli.py:112-118bareuvicorn.run; no lifespan/startup/shutdown/background task inwf_serverorwf_transport_rpc_http/app.py:27-68. All missing (expected; this is the work). - File-store single-process limits: per-process
RLockaround individual writes (runs/store.py:42-54); per-run async lock is process-local (runs.py:147,run_locks.py:15-55); zeroflock/fcntl/msvcrt/portalocker/FileLockhits insrc/; no PID/lease/ownership concept. Supported — matches2026-06-09-store-transaction-boundary.md. Scheduler ownership needs a Windows-tested held-lock design (that spec forbids ad-hoc lock files without one). - Stopped-run persistence implies durable admission: rejected.
run_deploymentproves execute-then-persist; there is no pre-dispatch record, no preassigned id, no admission lock, and no reconcile.
Gate 2 — Calendar-library probe
Isolated env (NOT the repo env): Python 3.14.7, apscheduler==3.11.0,
croniter==6.2.4, tzdata==2026.3, pytest==9.1.1, no pytz
(APScheduler used its zoneinfo path).
Reproduce:
$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=""
Result: 16 passed, 2 xfailed (Part A pins the croniter contract; Part B's 2 xfails are APScheduler rejected-candidate evidence — gap phantom and fold replay — marked strict so re-opening that candidacy fails loudly).
Policy: croniter owns calendar calculation, including DST resolution. Part A tests describe observed 6.2.4 behavior the thin adapter consumes (convert now into the schedule zone, ask for next/previous, convert back to UTC). No custom skip/filter machinery exists anymore.
- UTC daily chain (
30 9 * * *): strictly increasing, unique UTC instants over 10 occurrences. Pass. Asia/Ho_Chi_Minh: 09:30 local == 02:30 UTC; 48/48 hourly hits across March and November windows (no DST). Pass.- Expression forms on an ordinary day:
*→ next minute,5/15→ :05,0,30 1-2→ 01:00,*/20 1-3from 01:50 → 02:00. Pass. - Weekday dialect:
0AND7mean Sunday (weekday()=6),1/monmean Monday (0 12 * * 0 == 0 12 * * sun). Pass. - DOM/DOW:
day_or=Trueselected (Unix OR —0 12 13 * frihits 09-04, 09-11, 09-13, 09-18);day_or=Falsepinned as the non-selected AND reference. Pass. - Zone handling: a UTC start yields UTC results with no conversion — the caller supplies the schedule-zone instant. Pass (adapter rule).
- DST gap forward (America/New_York 2026-03-08): daily 02:30 resolves to 03:00-04:00 the same day; the following occurrence is 03-09 02:30. Per-minute streams jump 01:59 EST straight to 03:00 EDT with no 02:xx wall times, strictly increasing unique UTC. Pass (observed; the resolution IS the occurrence).
- DST gap backward:
get_prevon the gap day returns the resolved 03:00-04:00; from 03-09 00:00 the same; pre-gap queries return the prior valid occurrence. Pass (observed; latest-missed on a gap day is the resolved instant). - DST fold (2026-11-01 01:30): both repeats occur as distinct UTC
instants (
05:30Zthen06:30Z); per-minute iteration across the fold is 560/560 strictly increasing unique. Pass. - Long downtime, per-minute, ~3 years:
get_prevanswers the latest missed occurrence in ~0.0001s, tz-aware, within a minute of now (UTC and named-zone variants). Pass. - Boundary exclusivity:
get_next/get_prevfrom exactly a due instant return the neighboring occurrences; a tick just before due admits it. Pass (drives the T01 watermark rule below). - Impossible date (Feb 30): raises documented
CroniterBadDateErrorin ~0.001s. Pass (adapter maps it to exhausted). - Malformed expression:
CroniterBadCronErrorat construction. Naive datetimes pass straight through unrejected — the adapter rejects naive itself (spec requires rejection; no DST logic involved). Pass.
Rejected-candidate evidence (APScheduler 3.11.0; NOT acceptance):
- DST gap: APScheduler fabricates a phantom
02:30-05:00(=07:30Z, actually 03:30 EDT) — a wall time that never existed. FAIL (strict xfail). - DST fold per-minute: after
06:00Z, APScheduler flips back to-04:00and replays05:01Z–06:00Z(~59 duplicate UTC identities, UTC goes backward). FAIL (strict xfail). - No bounded latest-missed seam exists in its documented trigger API
surface (forward-only
get_next_fire_time); ~1.6M iterations would be needed for 3 years of per-minute misses.
Thin-adapter contract (replaces the retired skip rule)
T01 implements exactly this and nothing more:
- Convert the query instant into the schedule's named zone; ask
croniter for the next (
next_after) or previous (prev_before) occurrence; convert the result to UTC. - Iteration is exclusive both directions (pinned above), so forward queries start from the last-consumed instant and catch-up results are compared against the same watermark: a due occurrence is admitted exactly once, never missed by exclusivity nor doubled.
- Use the library's bounded-search controls and documented
exceptions:
CroniterBadDateErrormaps to exhausted;CroniterBadCronErrorat construction is a definition rejection. Search exhaustion or a backward/non-progressing result fails visibly — it must never look like successful progress, and the adapter must not "repair" results with its own calendar engine. - Admitted identity stays
(schedule_id, resolved UTC), which always exists. The definition's cron expression, time-zone name, and admission snapshots are retained; no finer intended-wall-time provenance is claimed. Minute precision (this slice).
Recommendation
- APScheduler has no bounded latest-missed seam in its documented trigger
API surface (forward-only
get_next_fire_time). The spec's "never enumerate years of missed occurrences" gate cannot be met with it;croniter.get_prevanswers in ~0.0004s. - APScheduler violates the occurrence-identity gate: it replays ~59 past UTC minutes after every fall fold (duplicates + backward UTC), which would double-admit per-minute schedules. croniter is clean (pinned).
- Across DST gaps APScheduler fabricates a nonexistent wall time; croniter resolves gap days forward to an existing wall time and emits both fold hours as distinct UTC occurrences (all pinned).
- The spec needs only iteration, not APScheduler's job store/executor
(which it already excludes). croniter is the smaller dependency with the
needed
get_prevseam and standard DOM/DOWday_or=True(pinned). - Pin
croniter==6.2.4+tzdatafloor (python-dateutilstays transitive-only); the probe pins gap/fold/dialect/zone behavior so upgrades re-probe (both strict xfails fail loudly if a future APScheduler fix tempts re-opening that candidacy, and the croniter pins — including the exact version assertion — catch drift).
Gate 3 — Scheduling state-model probe
probes/deployment_scheduling_verify/test_schedule_state_model.py:
pure-stdlib reference model (injected clock, scripted executor outcomes,
fault injection, ownership lock) + 31 tests, all passing in the repo
env:
uv run pytest -q `
probes/deployment_scheduling_verify/test_schedule_state_model.py `
-p no:cacheprovider -o addopts=""
The calendar is abstract (next_after/prev_before only — deliberately no
enumeration seam), so this pressures the state rules, not date math. Each
test is a short timeline, no sleeps. Coverage maps to the assignment bullets:
overlap=skip|parallel × misfire=skip|latest— all four combinations (incl. parallel limits + limit lowering + parallel×latest catch-up)- latest-means-ONE-candidate + supersession + no-double-admit at the boundary tick + newer-due-never-touches-admitted-runs + timely admission supersedes older held candidates
- manual runs and other schedules' runs excluded from per-schedule overlap
- run identities are store-backed and survive restart (no
_seq-style in-memory counter reuse — this was a real reference-model bug, fixed with a regression test) - terminal overlap skips never reappear after restart/catch-up
- terminal overlap skips never reappear after restart/catch-up
- per-schedule limits count interrupted runs; waiting interruptions hold no task slot; resume requires a task slot
- explicit pause is not downtime (pause excludes the interval; enabled
downtime catches up) via
resume_schedule/edit_schedulesemantics - edit discards candidates without backfill; delete clears pending, keeps history, never cancels active runs, ids not reusable
- restart persists candidate + consumed progress; rollback never re-admits
- 3-year per-minute downtime: ≤
SCAN_CAP + 2source calls, one interval-summary row, ≤1 admission (capacity) or exactly 1 held candidate - round-robin fairness across schedules; capacity-deadline expiry for skip vs hold-one for latest; capacity-wait within allowance (undecided, not consumed) then admit-or-expire
- fault before AND after admission (no dispatch; recovery materializes the view and flags pending-dispatch, the next poll dispatches through capacity checks exactly once — recovery NEVER executes), fault after materialize, fault after complete and after interrupt (recovery reconciles the missing terminal record), fault after resume-mark / resume-complete / resume-attempt-clear / resume-interrupt before AND after (attempt identity distinguishes fresh results from stale checkpoints), crash after dispatch (abandoned FAILED with external-effects disclosure, no replay), resumed runs re-interrupting durably
- crash during resume: ACTIVE-attempt marker distinguishes ambiguous (FAILED, never retried) from merely waiting (resumable)
- preflight rejection invents no run; admitted runs freeze invocation
- second-owner rejection; no-expiry held lock; unsupported locking rejects startup; corrupt view-without-admission fails closed and blocks the schedule
Key state findings for implementation (all encoded in the reference model):
- F1 — scan cap + jump rule. Per poll/schedule: at most
SCAN_CAP(100)next_aftercalls, then jump via oneprev_before(latest) or an interval-summary +consumed = now(skip). This is the executable form of "never enumerate"; onlyprev_beforemakes latest bounded (Gate 2). - F2 — consumed + candidate are the whole restart story.
consumed_through(latest decided instant) plus at most one pending candidate reconstruct everything; terminal skips/admissions are never rebuilt because their instants are ≤ consumed. - F3 — ordering inside admission: recheck → overlap → capacity → allocate/freeze → persist admission → materialize view → dispatch; the admitted-history entry belongs to the admission persist, not the view.
- F4 — resume marker first.
resume_attempts[run] = ACTIVEis durable before re-execution; recovery reconciles a matching stopped result and fails unmatched ACTIVE attempts closed. Pre-existing waiting interruptions (no marker) stay resumable — no migration problem. - F5 — dispatched RUNNING work at recovery is abandoned (in-memory tasks die with the process). Proven undispatched admissions remain pending; a run without an admission is corrupt and must block its schedule.
- F6 — one-shot exhaustion is a flag, set on admission or on skip-expiry; without it the expiry branch refires every poll.
- F7 — pause/edit/delete/disable are baseline operations
(
consumed = max(...)to now, clear candidates), not filters inside the poll loop. Resolved edge: administrative disable behaves like pause for catch-up (the spec defines no separate disable semantics). - F8 — timely admission supersedes older held candidates. A poll that admits instant N while a candidate for older instant C is held records C superseded and clears it; the admitted-history entry belongs to the admission persist (before the view materialization), so an admission-after crash still reconciles exactly once.
- F9 — recovery reconciles missing terminal records. A COMPLETED or waiting-INTERRUPTED run with no terminal history entry (crash between state persist and record append) gets exactly one reconciled entry; re-polling never re-admits because consumed already advanced. A COMPLETED run with a still-ACTIVE attempt crashed between completion and attempt-clearing: reconcile to DONE, never re-execute.
- F10 — run identities are store-backed. The id counter lives in the store, not the scheduler instance; restart allocates fresh identities and both occurrences keep their runs.
- F11 — recovery never executes. Runs materialized by recovery are flagged pending-dispatch: they hold a schedule slot but no task slot, and the next poll dispatches them through capacity checks (waiting while full). Pending runs of blocked schedules stay pending.
- F12 — resume completion is three persists, re-interruption is durable, and stopped results carry the attempt identity. Completion persist, attempt-clear, and history append each have a fault boundary; a resumed run may interrupt again (own persist, stays resumable). Every resume attempt takes a store-backed identity at mark time, and every stopped result it produces echoes that identity back: recovery matches result to ACTIVE attempt (fresh → DONE + resumable) versus stale (ambiguous → failed, never retried). Crashes inside completion windows still fail closed, but a persisted re-interruption is never mistaken for the previous checkpoint.
Sequenced implementation plan
Conventions: each task lists goal, exact seams/files, test-first source
(port the named probe tests into tests/scheduling/ — do not copy probe
logic into src/), and done criteria. Dependencies noted per phase.
Assumes the Gate 2 calendar policy (croniter owns DST resolution; thin
adapter, no custom skip/filter). New production code lives in a focused
package
src/wf_scheduling/ (new area; per AGENTS.md prefer packages over flat
files) plus the listed seam edits; no second evaluator, no fake workflow
context, no GraphSourcePath extension.
R0 — Review checkpoint (before any production code)
Reviewers confirm the thin-adapter contract, the croniter pin, and the
src/wf_scheduling/ package boundary. Gate: this document + green probes.
Phase 0 — Calendar adapter (no scheduler yet)
- T01 — Occurrence-source seam + croniter adapter. New
src/wf_scheduling/calendar.py:OccurrenceSourceProtocol (next_after,prev_beforeover aware datetimes),CronSource(5-field cron + IANA zone; converts now→zone, queries, returns UTC) andOneShotSource(offset-required, naive rejected). Deliberate dialect: croniter Unix convention (numeric0AND7= Sunday;day_or=Trueselected — NOT APScheduler's Monday-first). Thin by construction: no calendar correction of its own — forward queries start from the last-consumed instant, catch-up results compare against the same watermark (iteration is exclusive both directions, pinned),CroniterBadDateErrormaps to exhausted,CroniterBadCronErrorat construction is a definition rejection, and any backward/non-progressing result fails visibly. Addcroniter==6.2.4and atzdatafloor to dependencies (python-dateutilremains transitive-only). Port: all Part Atest_calendar_probe.pytests into adapter-level tests (the two Part B strict xfails stay as APScheduler rejected-candidate pins). Done: adapter suite green; impossible schedules map to exhausted (noCroniterBadDateErrorleaks). - T02 — Occurrence identity + UTC persistence helpers.
src/wf_scheduling/occurrences.py: identity =(schedule_id, resolved_utc);occurrence_idderived deterministically from that pair (document the derivation; spec exposes schedule_id/occurrence_id/scheduled_at); monotonic-clock sleep vs wall-clock eligibility split; rollback guard (resolved <= consumednever re-admitted). Port: calendar uniqueness/ increasing tests plus the reference-model rollback test. Depends: T01.
Phase 1 — Expression seam (review checkpoint R1)
- T03 — Extract shared traversal behind a typed source resolver.
New
src/wf_core/runtime/input_sources.pyhosting theSourceResolverProtocol; refactorsrc/wf_core/runtime/input_bindings.py:23-114so path resolution goes through it (graph root stays a resolver argument — noGraphSourcePathchange); unify the sibling recursions (src/wf_core/validation/steps.py:242-284,src/wf_api/input_expressions.py:99-253) onto one traversal + budget check. Existing node/subgraph/interrupt call sites (src/wf_core/runtime/ops/nodes.py:48-66,src/wf_core/runtime/subgraphs.py:139-145,src/wf_core/runtime/ops/interrupts.py:26-43) pass the graph resolver. Done: no behavior change; full existing suite green. New tests: resolver unit tests (graph vs occurrence resolvers over one traversal). - T04 — Typed occurrence expression kind.
New
OccurrenceExpression{field: schedule_id|occurrence_id|scheduled_at}insrc/wf_core/models/input_bindings.pyWITHOUT touchingsrc/wf_core/paths.pyroots; schedule-side binding list type (ScheduleInputBindings) reusingvalidate_input_expression_limits; resolution via an occurrence resolver (never via faked graphcontext). Strict-JSON/target-conflict/schema validation unchanged. Tests: JSON round-trip, missing-field, graph-only-path, over-budget, conflicting target, invalid-resolved-input — port the contract pins fromprobes/.../test_expression_contract_probe.py(8 tests documenting the current union/budget/strict-JSON/overlap/roots behavior) and extend them to the new kind. Depends: T03. - R1 — review: resolver Protocol, occurrence kind, budget parity.
Phase 2 — Durable admission representation (review checkpoint R2)
- T05 — Admission record + in-flight run view.
Extend
src/wf_artifacts/runs/models.py: admitted/in-flight status alongside stopped statuses (never fabricate checkpoint/trace/output for unknown outcomes); preassigned run identity from a store-backed counter (never an instance counter — restart must not reuse identities, per F10); admission persists the pinned environment (PinnedRunEnvironment: deployment + root/child artifacts), resolved input, limits (max_steps), deployment revision, and the occurrence's resolved UTC instant; run inspection distinguishes admitted vs stopped-with-checkpoint. Atomic-per-file writes stay; multi-file authority order per F3 (the occurrence is decided at the admission persist: history entry, candidate clearing, consumed progress, and one-shot exhaustion all belong to it). Port: fault-before/after-admission and admission-vs-view recovery tests (reference model, minus executor). Depends: T02. - T06 — Admission path through the run API.
Rework
WorkflowRunApi.run_deployment(src/wf_api/runs.py:77-134) into recheck → allocate/freeze → persist admission → materialize → dispatch-captured → persist stopped → reconcile, reusing the resolved-input signature (no second API). Manual runs keep the current call shape; existing manual run/resume tests must pass unchanged (regression gate). Depends: T05. R2 — review: admission ordering + inspection contract.
Phase 3 — Scheduler core (review checkpoint R3)
- T07 — Schedule/deployment-revision models + file store.
src/wf_scheduling/models.py(schedule, revision, overlap/misfire,max_active_runs, lateness allowance, zone, pause/delete/disable flags,exhaustedflag per F6, no id reuse) andsrc/wf_scheduling/store.py(FileScheduleStore: schedules, at-most-one candidate, consumed progress, interval summaries, occurrence history with cursor pagination over(resolved_utc, occurrence_id)+ limit). Add deployment revision (or content hash) toWorkflowDeployment(src/wf_artifacts/models.py:215,store.py:113) for edit-race rechecks. Port: edit/delete/pause/disable/no-backfill/id-reuse tests. Inspection payload carries resolved-UTC/admission/actual-start times, revision, run id, and failure/skip reason (spec lines 263-272). - T08 — Poll loop: overlap/misfire/candidates/fairness/capacity.
src/wf_scheduling/poll.py: F1 scan-cap rule, latest-only coalescing + supersession records, overlap-before-capacity precedence, terminal skips, capacity-deadline expiry (skip) vs hold-one (latest), round-robin fairness, per-poll batch bounds, task-slot accounting (interrupted and pending-dispatch runs hold schedule slots only), plus the pending-dispatch sweep (dispatch recovery-materialized runs through capacity checks; never inside recovery — F11). Port: matrix/coalescing/fairness/capacity/pending-dispatch reference tests. Depends: T02, T07. R3 — review: poll semantics vs reference model.
Phase 4 — Resume safety, recovery, ownership (review checkpoint R4)
Safety and recovery land BEFORE anything enables dispatch.
- T09 — Durable resume-attempt marker + granular completion.
resume_run(src/wf_api/runs.py:136-224) persists ACTIVE with a store-backed attempt identity before re-executing; completion, attempt-clearing, and history recording are separate persists with a fault boundary between each pair; every stopped result the attempt produces echoes the attempt identity back; a resumed run may interrupt again (durable re-interruption, stays resumable); waiting interruptions (no marker) stay resumable across restart. Port: crash-during-resume, resume-complete/attempt-clear/interrupt before+after fault, attempt-identity match/mismatch, and re-interruption tests. Depends: T06. - T10 — Startup recovery + reconciliation. Recovery under exclusive ownership, and recovery NEVER executes: it materializes missing views as pending-dispatch (dispatched later only via the poll sweep), fails abandoned/ambiguous runs with external-effects disclosure (no replay), matches stopped results to the ACTIVE attempt by identity (fresh → DONE + resumable; stale → ambiguous FAILED, never retried), reconciles missing terminal records and COMPLETED-with-ACTIVE attempts (to DONE, never re-run), fails corrupt views closed + blocks the schedule, and preserves stopped interruptions in their slots. Port: all recovery reference tests. Depends: T06, T08, T09.
- T11 — Exclusive file-store ownership.
Held cross-process lock (no PID file, no expiring lease), second-owner
rejection, release on death, startup rejection where locking is
unsupported — consistent with
2026-06-09-store-transaction-boundary.md(no ad-hoc lock files without a tested design). Start with a locking-design spike: candidates are a dedicated lock file held viamsvcrt.locking(Windows) /fcntl.flock(POSIX) in one new module, vs aportalocker-style dependency; pick after a Windows crash-hold-release test. Port: ownership reference tests. Depends: T07. R4 — review: fault-injection suite (every boundary before/after).
Phase 5 — Lifecycle enablement and administration surface
-
T12 — Shutdown drain + lifecycle (GATED: only after R4 passes). Opt-in scheduler composition on
WorkflowServer(src/wf_server/context.py:279-341; startup insrc/wf_server/cli.py; transport hooks insrc/wf_transport_rpc_http/app.py:27-68): explicitly-enabled flag, stop-admission-first + grace-period drain (scheduler_drain_grace_sconfig) with cancellation/failure recording. Dispatch must not be enabled before resume safety (T09), recovery (T10), and ownership (T11) land. Port: paused/deleted-schedule completion/resume reference tests; drain and bounded-concurrency tests are NEW (no probe source — the reference model has no server). Depends: T08, R4. -
T13 — API + Python client. New
WorkflowApischedule methods (create/get/list/update/pause/resume/ delete_schedule— public names finalized in this task, not assumed to exist) + paginated occurrence inspection (pending, coalesced/superseded, skipped-overlap, skipped-misfire, preflight-rejection, admitted/running, interrupted, completed, failed) throughwf_api/service.py+wf_api/surface.py, JSON-RPC transport (wf_transport_rpc_http), andwf_client(protocols.py,deployments.py,app.py,runs.py); revision-checked edits; run limits incl. schedulemax_stepsreusing manual-run validation; inspection without a checkpoint must work (admitted runs have none). Client round-trip/pagination tests are NEW (no probe source — the reference model has no transport). Depends: T08, T10. -
T14 — Docs + seam comments + probe retirement. Update live docs per
docs/AGENTS.md(roadmap pointers, no narrative bloat), add code-seam comments where docs describe behavior, deleteprobes/deployment_scheduling_verify/once production tests subsume it. Markdown lint on changed files only. Depends: everything.
Non-goals (unchanged): wait nodes, distributed workers, automatic
failed-run retries, replay-all bursts, generic Runtime[UserContext],
exactly-once external effects, multi-writer file stores beyond scheduler
ownership.
Remaining concerns
- No product decisions block Phase 0. The 60-second default lateness allowance is approved (per-schedule configurable).
CroniterBadDateErrorfrom impossible-schedule search and naive-time passthrough are adapter-level (map to exhausted / reject; no spec change needed).- Locking mechanism (T11) needs a Windows-tested design before code, per the store transaction boundary — flagged as a task, not a decision.
- Probes use scripted/abstract occurrence sources; production cron parsing arrives via T01's adapter, already pinned by the calendar probe.
Planning bundle files
docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md— updated worktree specification incorporating the approved calendar policyprobes/deployment_scheduling_verify/README.md— new (disposable label; isolated-env + repo-env run commands)probes/deployment_scheduling_verify/test_calendar_probe.py— new (16 passed, 2 strict xfailed in the isolated env: Part A pins the croniter contract, Part B keeps APScheduler rejected-candidate evidence;importorskipkeeps default repo collection green)probes/deployment_scheduling_verify/test_schedule_state_model.py— new (31 passed in the repo env)probes/deployment_scheduling_verify/test_expression_contract_probe.py— new (8 passed in the repo env; pins the current expression contract for Phase 1)docs/superpowers/plans/2026-09-09-deployment-scheduling-implementation-plan.md— this file (new; reviewed three times — first pass B1–B8/A1–A3, second pass (identity/ordering fixes), third pass: skip rule corrected to expanded sets with work-bounded forward/backward forms, attempt identity for stopped results, lifecycle enablement gated after safety/recovery/ownership; fourth pass: custom skip/filter machinery retired — croniter owns DST resolution, thin-adapter contract with last-consumed watermark,day_or=Trueselected, resolved-UTC identity throughout)