Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ it never submits roster changes to Sleeper.
- ESPN and official NBA injury reports provide current schedules, results, and availability.
- Historical SportsDataverse data supports projection experiments and backtesting.
- Local commands persist state in SQLite; the deployed Cloudflare Worker uses D1.
- Notification actions record acknowledgements in Sleeper Manager only. The manager still
makes every roster or Lock-In change in Sleeper.
- The five-minute scheduled wake captures pre-tipoff starters, watches ESPN finals,
and recommends Lock or Pass until acknowledgement or deadline.

See [LOCK_IN_MODE.md](LOCK_IN_MODE.md) for the league rules enforced by the decision engine.

Expand Down Expand Up @@ -80,10 +80,12 @@ Local commands persist in SQLite; only `STATE_BACKEND=sqlite` is supported. The
Worker uses D1 and does not read this variable.

Manager policy TOML is the local source of manager intent: decision preset and confidence,
notification quiet hours, protected players, and mapping overrides. `sync-cloudflare-runtime-data`
notification quiet hours, and mapping overrides. `sync-cloudflare-runtime-data`
translates that intent into the deployed runtime policy envelope in D1, including a content
hash stamped on live plans as `manager_policy_version`. Version-one policy files reject removed
keys such as `use_matchup_context`, `protect_elite_upside`, `daily_summary`, and `injury_alerts`.
keys such as `use_matchup_context`, `protect_elite_upside`, `daily_summary`, `injury_alerts`,
and `protected_sleeper_ids`. Quiet-hour fields are accepted and stored, but quiet-hour
suppression is deferred. Live Lock-In advice uses the resolved minimum-confidence threshold.
Changing `.local/policy.toml` does not affect the Worker until the next runtime-data sync.

## Commands
Expand Down
26 changes: 19 additions & 7 deletions docs/cloudflare-runtime.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# Cloudflare runtime

The Worker uses one five-minute Cron Trigger. Each wake claims due daily, pre-tipoff, or
delivery-retry work and runs the weekly lineup planner at most once. Lineup notifications
include Open Sleeper only. The `test-notification` command remains a local diagnostic
and is never invoked from `scheduled()`.
The Worker uses one five-minute Cron Trigger. Each wake claims due daily, pre-tipoff,
postgame, or delivery-retry work. Daily and pre-tipoff wakes run the weekly lineup
planner at most once and refresh live Lock-In opportunity rows. Postgame wakes fetch
ESPN game summaries directly, stabilize finals, and may send Lock, Pass, or
unavailable-warning notifications. Lineup notifications include Open Sleeper only.
Lock-In notifications include Locked, Passed, and Open Sleeper. The `test-notification`
command remains a local diagnostic and is never invoked from `scheduled()`.

Projection history and the active runtime policy live in D1. They are not bundled into the
Worker deploy.
Expand All @@ -25,9 +28,18 @@ activates runtime policy last.

The sync also translates the local manager policy TOML (`.local/policy.toml` by default) into
the runtime policy envelope. That translation copies decision preset and confidence, quiet-hour
settings, protected players, mapping overrides, and a content hash used as
`manager_policy_version` on live plans. Operational fields such as freshness windows and the
pinned projection-history version remain runtime defaults unless changed in code.
settings, mapping overrides, and a content hash used as `manager_policy_version` on live plans.
`protected_sleeper_ids` is rejected in new TOML and accepted only as an empty legacy runtime
value. Quiet-hour fields are stored for later use; quiet-hour suppression is not enforced.
Live Lock-In evaluation consumes the resolved minimum-confidence threshold. Operational fields
such as freshness windows and the pinned projection-history version remain runtime defaults
unless changed in code.

Postgame watches start two hours after tipoff and retry every five minutes until the
opportunity is acknowledged, expired, or otherwise closed. Lock and Pass notifications
include Locked, Passed, and Open Sleeper actions. Unavailable warnings include Open Sleeper
only. Acknowledgement updates recommendation and opportunity state together; a later ESPN
stat correction refreshes the locked score without sending another action.

`--apply` requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in the environment. Do
not print those values; Wrangler secrets stay in the Worker environment:
Expand Down
89 changes: 89 additions & 0 deletions infra/cloudflare/migrations/0004_live_lock_in.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
PRAGMA defer_foreign_keys = ON;

CREATE TABLE scheduled_work_v4 (
work_id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL CHECK (
kind IN ('daily', 'pre_tipoff', 'delivery_retry', 'postgame')
),
due_at TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('pending', 'running', 'retry', 'completed', 'canceled')
),
local_day TEXT,
game_id TEXT,
recommendation_id TEXT,
deadline TEXT,
lease_expires_at TEXT,
attempt_count INTEGER NOT NULL DEFAULT 0,
correlation_id TEXT,
failure_category TEXT,
terminal_summary_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (recommendation_id) REFERENCES recommendations(recommendation_id)
);

INSERT INTO scheduled_work_v4 (
work_id, dedupe_key, kind, due_at, status, local_day, game_id,
recommendation_id, deadline, lease_expires_at, attempt_count,
correlation_id, failure_category, terminal_summary_json, created_at, updated_at
)
SELECT
work_id, dedupe_key, kind, due_at, status, local_day, game_id,
recommendation_id, deadline, lease_expires_at, attempt_count,
correlation_id, failure_category, terminal_summary_json, created_at, updated_at
FROM scheduled_work;

DROP TABLE scheduled_work;
ALTER TABLE scheduled_work_v4 RENAME TO scheduled_work;

CREATE INDEX scheduled_work_due_idx
ON scheduled_work (status, due_at, lease_expires_at);

CREATE TABLE lock_in_opportunities (
league_id TEXT NOT NULL,
fantasy_week INTEGER NOT NULL,
roster_id INTEGER NOT NULL,
player_id TEXT NOT NULL,
game_id TEXT NOT NULL,
provider_player_id TEXT NOT NULL,
scheduled_start TEXT NOT NULL,
action_deadline TEXT NOT NULL,
fantasy_week_end TEXT NOT NULL,
status TEXT NOT NULL,
next_check_at TEXT NOT NULL,
slot_index INTEGER,
slot_position TEXT,
eligible_positions_json TEXT NOT NULL,
rostered_at_tipoff INTEGER,
roster_evidence_at TEXT,
league_configuration_fingerprint TEXT,
current_observed_score REAL,
current_observation_fingerprint TEXT,
consecutive_direct_poll_count INTEGER NOT NULL DEFAULT 0,
current_poll_id TEXT,
previous_observed_at TEXT,
current_observed_at TEXT,
stable_score REAL,
stable_fingerprint TEXT,
stabilized_at TEXT,
score_revision INTEGER NOT NULL DEFAULT 0,
current_recommendation_id TEXT,
current_recommendation_kind TEXT,
acknowledged_action TEXT,
acknowledged_at TEXT,
latest_evaluation_hash TEXT,
trace_json TEXT NOT NULL DEFAULT '{}',
row_version INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (league_id, fantasy_week, roster_id, player_id, game_id),
FOREIGN KEY (current_recommendation_id) REFERENCES recommendations(recommendation_id)
);

CREATE INDEX lock_in_opportunities_due_idx
ON lock_in_opportunities (status, next_check_at, action_deadline);

CREATE INDEX lock_in_opportunities_ack_idx
ON lock_in_opportunities (league_id, fantasy_week, status, roster_id);
2 changes: 1 addition & 1 deletion manager-policy.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ preset = "balanced"
minimum_confidence = 0.70

[notifications]
# Accepted and stored; quiet-hour suppression is not enforced yet.
quiet_hours_start = "23:00"
quiet_hours_end = "07:00"
urgent_actions_override_quiet_hours = true

[players]
protected_sleeper_ids = []
mapping_overrides = {}
5 changes: 5 additions & 0 deletions src/sleeper_manager/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
)
from sleeper_manager.cloudflare.dispatcher import dispatch_due_work
from sleeper_manager.cloudflare.planning import collect_cloudflare_planning_inputs
from sleeper_manager.cloudflare.providers import CloudflareESPNProvider
from sleeper_manager.cloudflare.runtime_sync import (
RemoteD1,
compact_history_from_workspace,
Expand All @@ -58,6 +59,7 @@
NotificationLoop,
default_placeholder_request,
)
from sleeper_manager.workflows.postgame_lock_in import LOCK_IN_ACKNOWLEDGEMENT_KINDS


def build_parser() -> argparse.ArgumentParser:
Expand Down Expand Up @@ -302,6 +304,7 @@ async def _test_notification(settings: Settings) -> int:
repository,
dispatcher,
acknowledgement_base_url=settings.acknowledgement_base_url,
acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS,
).run(
default_placeholder_request(
league_id=settings.sleeper_league_id or "local-diagnostic",
Expand Down Expand Up @@ -444,6 +447,7 @@ async def _run_scheduled(settings: Settings) -> int:
repository,
build_notification_dispatcher(settings),
acknowledgement_base_url=settings.acknowledgement_base_url,
acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS,
)
now = datetime.now(UTC)
env = SimpleNamespace(
Expand Down Expand Up @@ -472,6 +476,7 @@ async def collect(*, repository, policy, scheduled_at): # type: ignore[no-untyp
scheduled_at=now,
correlation_id=uuid4().hex,
open_sleeper_url="https://sleeper.com",
fetch_game_summary=CloudflareESPNProvider(fetch, clock=lambda: now).game_summary,
)
except (OSError, RuntimeError, ValueError) as error:
print(redact_secrets(f"Scheduled run failed: {error}"), file=sys.stderr)
Expand Down
52 changes: 48 additions & 4 deletions src/sleeper_manager/cloudflare/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Protocol

from sleeper_manager.cloudflare.planning import CloudflarePlanningAssembly
from sleeper_manager.cloudflare.postgame_dispatch import dispatch_postgame_work
from sleeper_manager.cloudflare.scheduler_types import (
FailureCategory,
FreshnessDetail,
Expand All @@ -34,8 +35,13 @@
)
from sleeper_manager.projections.live_baseline import ProjectionHistoryError
from sleeper_manager.workflows.daily_plan import WeeklyLineupWorkflowResult, run_daily_plan
from sleeper_manager.workflows.lock_in_planning import (
refresh_terminal_lock_in_scores,
sync_live_lock_in_opportunities,
)
from sleeper_manager.workflows.notification_loop import NotificationLoop
from sleeper_manager.workflows.planning_inputs import LivePlanningInputs
from sleeper_manager.workflows.postgame_lock_in import DirectGameSummarySource
from sleeper_manager.workflows.pre_tipoff_check import run_pre_tipoff_check

_RETRY_DELAY = timedelta(minutes=5)
Expand Down Expand Up @@ -71,8 +77,9 @@ async def dispatch_due_work(
open_sleeper_url: str,
plan_policy: WeeklyPlanPolicyConfig | None = None,
clock: Callable[[], datetime] | None = None,
fetch_game_summary: DirectGameSummarySource | None = None,
) -> ScheduledRunSummary:
"""Dispatch claimed daily, pre-tipoff, and delivery-retry rows for this wake."""
"""Dispatch claimed daily, pre-tipoff, postgame, and delivery-retry rows for this wake."""
if scheduled_at.tzinfo is None or scheduled_at.utcoffset() is None:
raise ValueError("Scheduled time must be timezone-aware")
tick = clock or (lambda: scheduled_at)
Expand All @@ -89,6 +96,7 @@ async def dispatch_due_work(

await _ensure_daily_work(repository, policy, scheduled_at)
await repository.cancel_expired_scheduled_work(scheduled_at)
await repository.expire_lock_in_opportunities(scheduled_at)
claimed = await repository.claim_due_work(scheduled_at, correlation_id=correlation_id)
if not claimed:
return ScheduledRunSummary(
Expand All @@ -98,7 +106,12 @@ async def dispatch_due_work(
claimed_count=0,
)

planning = tuple(item for item in claimed if item.kind is not DueWorkKind.DELIVERY_RETRY)
planning = tuple(
item
for item in claimed
if item.kind not in {DueWorkKind.DELIVERY_RETRY, DueWorkKind.POSTGAME}
)
postgame = tuple(item for item in claimed if item.kind is DueWorkKind.POSTGAME)
retries = tuple(item for item in claimed if item.kind is DueWorkKind.DELIVERY_RETRY)
attempts: list[WorkAttemptSummary] = []
if planning:
Expand All @@ -114,6 +127,22 @@ async def dispatch_due_work(
open_sleeper_url=open_sleeper_url,
plan_policy=plan_policy,
clock=tick,
fetch_game_summary=fetch_game_summary,
)
)
if postgame:
attempts.extend(
await dispatch_postgame_work(
repository,
postgame,
policy=policy,
notifications=notifications,
collect=collect,
fetch_summary=fetch_game_summary,
scheduled_at=scheduled_at,
correlation_id=correlation_id,
open_sleeper_url=open_sleeper_url,
clock=tick,
)
)
for work in retries:
Expand Down Expand Up @@ -178,6 +207,7 @@ async def _run_planning(
open_sleeper_url: str,
plan_policy: WeeklyPlanPolicyConfig | None,
clock: Callable[[], datetime],
fetch_game_summary: DirectGameSummarySource | None = None,
) -> tuple[WorkAttemptSummary, ...]:
trigger = (
"pre_tipoff" if any(item.kind is DueWorkKind.PRE_TIPOFF for item in claimed) else "daily"
Expand Down Expand Up @@ -210,15 +240,29 @@ async def _run_planning(
retry=True,
)

planning_inputs = assembly.evidence.inputs
await _replace_future_pre_tipoff(
repository,
policy,
assembly.evidence.inputs,
planning_inputs,
scheduled_at,
)
await sync_live_lock_in_opportunities(
planning_inputs,
repository=repository,
observed_at=assembly.evidence.decision_time,
)
if trigger == "daily" and fetch_game_summary is not None:
planning_inputs = await refresh_terminal_lock_in_scores(
planning_inputs,
repository=repository,
fetch_summary=fetch_game_summary,
observed_at=assembly.evidence.decision_time,
poll_id=f"{correlation_id}:daily-refresh",
)
workflow = run_pre_tipoff_check if trigger == "pre_tipoff" else run_daily_plan
result = await workflow(
assembly.evidence.inputs,
planning_inputs,
decision_time=assembly.evidence.decision_time,
repository=repository,
notifications=notifications,
Expand Down
Loading
Loading