Scheduling Engine
The Scheduling Engine (scripts/scheduling/) tracks frequency-based goals (like "haircut every 4 weeks" or "2 skating sessions per week"), detects when goals are drifting off-schedule, and suggests optimal times based on learned patterns. It integrates with the VDB daily brief system to surface timely warnings and recommendations.
Note β naming collision. This page documents the frequency-goal Scheduling Engine (cadence tracking for life goals). That is a different thing from VitaScheduler, the background-task loop engine that runs ~65 named async jobs inside the API process. VitaScheduler is summarized in the VitaScheduler vs. Scheduling Engine section near the bottom and in Architecture; the rest of this page is the goal-cadence engine. See also Goals for the goal schema/lifecycle these goals plug into.
Why This Exists
Life goals fall into two categories: 1. Project goals - Have a clear end state (e.g., "lose 10 lbs") 2. Frequency goals - Ongoing habits that need regular repetition (e.g., "checkup every 90 days")
Frequency goals are easy to forget. You think "we just did that" but it was actually 3 weeks ago. This engine solves that by: - Tracking when activities actually happen - Warning before goals drift overdue - Learning your preferred times so suggestions feel natural - Auto-updating counters on interval goals
Two Goal Types
Interval Goals
Things that should happen every N days. Examples: - Haircut every 28 days - Weigh-in every 7 days - Doctor checkup every 90 days
Day 0 Day 12 Day 14 Day 17
|-------------|------------|------------|
Last occ Warning Overdue Critical
starts starts starts
Weekly Goals
Things with multiple components that reset each week. Example: - Training structure: 2 skate sessions, 2 zone-2 cardio, 3 strength days
Mon (4am) Mon (4am)
|--- Week boundary ---| |--- Next week ---|
Current count: 1/2 skating
Days left: 3
Status: ON_TRACK (can still hit target)
Trigger Points
| Trigger | Action | Implementation |
|---|---|---|
/schedule command |
Show drift status for all goals | cli.py drift-status --format=text |
/schedule log <goal> |
Log occurrence of a goal | cli.py log-occurrence <goal> |
/schedule backfill <goal> |
Batch-log historical dates | cli.py backfill <goal> '<dates>' |
| VDB generation | Generate scheduling section for daily brief | engine.get_scheduling_section() |
| RWGPS sync | Auto-log cycling activities | rwgps_auto_logger.py |
| Garmin sync | Auto-log activities | activity_matcher.py |
| Precedent habits | Auto-log from external tracker | Training goals skill |
Process Flow
Logging an Occurrence
User: /schedule log training --component=skate
+-------------------+
| CLI (cli.py) |
| 1. Parse args |
| 2. Resolve goal |
| 3. Default date/ |
| time to now |
+--------+----------+
|
v
+-------------------+
| Engine |
| 1. Validate src |
| 2. Resolve comp |
| 3. Check dupes |
| 4. Acquire lock |
| 5. Append JSONL |
| 6. Update patterns|
| 7. Increment goal |
| counter |
+--------+----------+
|
v
+-------------------+
| Pattern Learner |
| 1. Extract day/ |
| time slot |
| 2. Update weights |
| 3. Normalize |
+-------------------+
VDB Section Generation
VDB Template Request
|
v
+-------------------------+
| get_scheduling_section()|
| 1. Load frequency goals |
| 2. Check each for drift |
| 3. Collect warnings |
| 4. Suggest times for |
| top drifting goal |
| 5. Get today's commits |
+------------+------------+
|
+-----------+-----------+
| | |
v v v
drift_ suggest_ commitment_
warnings time_for_ tracker
[] goal() .get_pending()
Key Design: get_scheduling_section() catches all exceptions internally. It must never crash the VDB generation - returns empty dict on any failure.
Sleep Gate
Per Board Resolution RES-mtg-c9207983-agenda-1 (Sleep Recovery Protocol): High-intensity training is suspended when the 7-day sleep average drops below 6.5 hours.
Location: scripts/scheduling/sleep_gate.py
How It Works
The sleep gate reads Garmin daily JSON files (data/garmin/*.json, the sleep.duration_hours field) to calculate a 7-day rolling sleep average. The gate only engages for high-intensity components β check_sleep_gate(component) matches the component name against a fixed keyword set (sleep_gate.py:HIGH_INTENSITY_COMPONENTS):
| Trigger | Note |
|---|---|
max_effort_interval |
legacy component name |
bike_intervals |
|
intervals |
|
hiit |
|
sprints |
|
ftp |
|
max_effort |
Any other component returns immediately as not blocked. For a high-intensity component the gate checks:
- If no sleep data exists: warn but don't block
- If 7-day average <
SLEEP_THRESHOLD(6.5 hours): block the occurrence withSleepGateError - If 7-day average >= 6.5 hours: allow
from scripts.scheduling.sleep_gate import check_sleep_gate
result = check_sleep_gate("bike_intervals")
# result.is_blocked -> True/False
# result.avg_sleep_hours -> 6.2 (or None if no data)
# result.days_with_data -> 7
# result.message -> "SLEEP GATE ACTIVE: 7-day average is 6.2h..."
Source of Truth: the Gate Framework
check_sleep_gate() does not decide the block by comparing against 6.5 itself anymore. After computing the 7-day average, it delegates the block decision to the executive gate framework:
from scripts.executive.gates import is_action_blocked
blocked, gate_result = is_action_blocked(f"training.{component}")
is_action_blocked() evaluates all configured gates and returns True if any active gate lists training.<component> in its blocks set (scripts/executive/gates.py). The direct avg_sleep < 6.5 comparison only runs as a fallback if that import or call raises. So a reimplementer should treat the executive gate framework as canonical and sleep_gate.py as the training-specific entry point that still owns the 7-day average computation and the human-readable message. See Executive for the gate framework.
Engine Integration
The log_occurrence() function in engine.py calls check_sleep_gate() before writing any occurrence. If the gate blocks, a SleepGateError is raised (which includes the SleepGateResult for display purposes). The check can be bypassed with bypass_sleep_gate=True for manual overrides.
Note β Predictive Sleep Guardian. A separate trio of modules physically lives in
scripts/scheduling/(sleep_guardian_learn.py,sleep_predictor.py,sleep_risk.py,sleep_guardian_backfill.py) and forecasts sleep-risk rather than gating training. They are documented on the Sleep Guardian page, not here, despite the shared directory.
Where Sleep Gate Is Checked
engine.py:log_occurrence()- Before logging any occurrence/checkinsessions - Status reported at start/logcommands - When logging training activities- VDB daily brief - Sleep gate status in health section
Key Components
1. Goal Resolution (goals.py)
Goals can be referenced three ways:
- UUID: a1a1a1a1-2222-3333-4444-555555555555
- Filename stem: weekly-training-structure
- Short alias: training (first word of filename)
The alias system auto-generates from files in goals/active/:
# Example resolution
resolve_goal("training")
# Returns: ("a1a1a1a1-...", {...goal dict...})
resolve_goal("haircut")
# Returns: ("b1c2d3e4-...", {...goal dict...})
Component aliases for weekly goals are defined in the goal's frequency.components field:
{
"components": {
"skate_sessions": {"target": 2, "aliases": ["skate", "skating"]},
"zone_2_cardio": {"target": 2, "aliases": ["zone2", "cardio", "cycling"]}
}
}
Goal Counter Updates: When an occurrence is logged for an interval goal with a current_value field, the counter is automatically incremented. When an occurrence is deleted, it's decremented. This keeps progress tracking in sync.
Caching: The goals directory mtime is checked before alias resolution. Cache is invalidated when any goal file changes.
2. Occurrence Logging (engine.py)
The core log_occurrence() function handles:
- Source validation: Must be one of
manual,checkin,garmin,rwgps - Component resolution: For weekly goals, resolves aliases to canonical names
- Sleep gate check: For high-intensity components (
max_effort_interval,bike_intervals,intervals,hiit,sprints,ftp,max_effort), checks 7-day sleep average via the gate framework. RaisesSleepGateErrorif blocked. Can be bypassed withbypass_sleep_gate=True. - Duplicate detection:
- Same
garmin_activity_idorrwgps_trip_id - Same goal+component within 2-hour window
- Same goal+component+date for manual entries (unless
skip_date_dedup=True) - Atomic file writing: Uses
fcntl.flock()with 5-second timeout - Pattern updates: Triggers pattern learning after successful log
- Counter updates: Increments
current_valueon interval goals
File locking strategy: The engine uses non-blocking exclusive locks with a polling retry loop. If lock cannot be acquired within 5 seconds, a RuntimeError is raised. This prevents data corruption during concurrent access from CLI, skills, and auto-loggers.
Duplicate detection window: 2 hours was chosen to allow legitimate back-to-back sessions (e.g., morning and evening workouts) while catching accidental double-logs.
3. Drift Detection (drift_detector.py)
Interval Goals
check_interval_goal(goal) -> DriftResult
Status thresholds:
- ON_TRACK: days_since < drift_warning_days
- WARNING: drift_warning_days <= days_since < target_interval_days
- OVERDUE: target_interval_days <= days_since <= target + tolerance
- CRITICAL: days_since > target + tolerance
- NO_DATA: No occurrences logged
- QUIET: quiet_mode enabled in goal
Example configuration (an interval goal):
{
"frequency": {
"type": "interval",
"target_interval_days": 14,
"drift_warning_days": 12,
"tolerance_days": 3
}
}
Weekly Goals
check_weekly_goal(goal) -> dict[str, DriftResult]
For each component, calculates:
- count: occurrences this week
- remaining: target - count
- days_left: days until week resets
Status:
- ON_TRACK: count >= target, OR remaining <= days_left
- WARNING: remaining > days_left (can't hit target even if daily)
Week boundary calculation: Configurable via week_starts (default: monday) and week_reset_hour (default: 4am PT to avoid DST confusion).
Timestamp Parsing
The drift detector handles multiple timestamp formats for compatibility:
1. occurred_at - canonical field from engine (ISO-8601 with TZ)
2. logged_at - fallback for skill-written data
3. date + logged_at time - for legacy records with date string only
This allows old occurrence data (from before the engine was standardized) to still work with drift detection.
4. Pattern Learning (pattern_learner.py)
Learns preferred days and times from historical occurrences:
TIME_SLOTS = {
"morning": (6, 12), # 6am-12pm
"afternoon": (12, 17), # 12pm-5pm
"evening": (17, 22), # 5pm-10pm
}
Patterns are stored per goal (or goal:component for weekly goals) with normalized weights:
{
"goals": {
"a1a1a1a1-...:skate_sessions": {
"day_weights": {"saturday": 0.6, "sunday": 0.4},
"time_weights": {"morning": 0.8, "afternoon": 0.2},
"occurrence_count": 12,
"last_updated": "2026-01-02T16:37:01-08:00"
}
}
}
Minimum data threshold: 3 occurrences required before patterns influence suggestions. Falls back to goal's preferred_days/preferred_times otherwise.
Decay mechanism: decay_old_patterns() applies exponential decay (0.85^(months)) to patterns not updated in 90+ days. This ensures stale patterns don't override current preferences.
5. Calendar Optimization (calendar_optimizer.py)
Suggests available time slots ranked by preference:
find_available_slots(
calendar_events: Optional[list],
preferred_days: Optional[list],
preferred_times: Optional[list],
days_ahead: int = 7,
goal_id: Optional[str] = None,
component: Optional[str] = None
) -> list[dict]
Scoring (max 1.0), from calendar_optimizer.find_available_slots:
- +0.3: Matches preferred day
- +0.3: Matches preferred time slot
- +0.4: Calendar data present AND no conflicting event in the slot (_events_in_slot returns false)
- +0.2: No calendar data at all (assume available β partial-credit fallback)
- slot dropped entirely if calendar data exists and an event conflicts with the slot
Calendar integration is wired end-to-end (not a stub). The VDB context builder reads data/calendar/events.json and threads it through: scripts/vdb/gather_context.py does calendar_events = sources.get("calendar") or [] and passes it into get_scheduling_section(calendar_events=...), which forwards it to suggest_time_for_goal β find_available_slots. So when calendar data is present, _events_in_slot actually scores conflicts (+0.4 clean, slot dropped on conflict); the +0.2 branch is only the fallback when there is no calendar data for the window. See Garmin & Calendar data sources for how events.json is populated.
6. Commitment Tracking (commitment_tracker.py)
Tracks follow-up items for scheduled activities:
add_commitment(goal_id, text, scheduled_date, source) -> str
# Creates cmt_xxxxxxxx with automatic follow_up_date (day after scheduled)
auto_close_on_occurrence(goal_id, occurred_at) -> Optional[str]
# When occurrence logged, finds matching pending commitment within +/-1 day
Auto-close logic: If you say "skate session Saturday" in a check-in, a commitment is created. When you log a matching occurrence on Friday, Saturday, or Sunday, the commitment auto-closes as "done".
7. Validators (validators.py)
Provides validation functions that return lists of error strings (empty if valid):
validate_occurrence(occ)- Validates an occurrence record:idmust matchocc_+ 8 hex charssourcemust be inVALID_SOURCES(manual,checkin,garmin,rwgps)goal_idmust be valid UUIDoccurred_atmust be ISO-8601 with timezone offsetvmust be integer >= 1-
Component must be valid for weekly goals
-
validate_frequency_field(freq, goal_type)- Validates a goal's frequency configuration: - Interval goals: requires
target_interval_days,drift_warning_days,tolerance_days - Weekly goals: requires
componentsdict withtargetvalues -
Cross-validates (e.g., warning days < target days)
-
validate_component(goal, component)- Resolves and validates component names against goal
Data Structures
Occurrence Record (JSONL)
Stored in data/scheduling/occurrences-YYYY-MM.jsonl:
{
"id": "occ_c2199448",
"goal_id": "a1a1a1a1-2222-3333-4444-555555555555",
"occurred_at": "2025-12-22T09:53:35-08:00",
"logged_at": "2026-01-01T12:01:08.967664-08:00",
"source": "rwgps",
"v": 1,
"component": "max_effort_interval",
"rwgps_trip_id": "357495455",
"notes": "Auto-logged from RWGPS trip 357495455"
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | occ_ + 8 hex chars |
goal_id |
string | Yes | Goal UUID |
occurred_at |
ISO8601 | Yes | When activity happened (with TZ offset) |
logged_at |
ISO8601 | Yes | When record was created |
source |
string | Yes | One of: manual, checkin, garmin, rwgps |
v |
int | Yes | Schema version (currently 1) |
component |
string | No | Component name for weekly goals |
garmin_activity_id |
string | No | Garmin ID for deduplication |
rwgps_trip_id |
string | No | RWGPS ID for deduplication |
notes |
string | No | Free-text notes |
deleted |
bool | No | Soft-delete flag |
Legacy format: Older records may have date (YYYY-MM-DD) instead of occurred_at. The drift detector handles both formats.
Goal Frequency Configuration
Interval Type
{
"frequency": {
"type": "interval",
"target_interval_days": 14,
"drift_warning_days": 12,
"tolerance_days": 3,
"preferred_days": ["friday", "saturday"],
"preferred_times": ["evening"],
"quiet_mode": false,
"max_warnings_per_session": 1
}
}
Weekly Type
{
"frequency": {
"type": "weekly",
"week_starts": "monday",
"week_reset_hour": 4,
"components": {
"skate_sessions": {"target": 2, "aliases": ["skate", "skating"]},
"zone_2_cardio": {"target": 2, "aliases": ["zone2", "cardio"]}
},
"quiet_mode": false
}
}
Patterns File
data/scheduling/patterns.json:
{
"schema_version": 1,
"updated_at": "2026-01-02T16:37:01-08:00",
"goals": {
"goal_uuid:component_name": {
"day_weights": {"saturday": 0.6, "sunday": 0.4},
"time_weights": {"morning": 0.8, "afternoon": 0.2},
"occurrence_count": 12,
"last_updated": "2026-01-02T16:37:01-08:00"
}
}
}
Commitment Record
data/assistant/commitments.json:
{
"schema_version": 2,
"commitments": [
{
"id": "cmt_a1b2c3d4",
"goal_id": "b1c2d3e4-0000-0000-0000-000000000000",
"text": "Skate session Saturday",
"scheduled_date": "2026-01-04",
"follow_up_date": "2026-01-05",
"source": "checkin",
"status": "pending",
"created_at": "2026-01-02T10:00:00-08:00",
"closed_at": null,
"outcome": null
}
]
}
File Locations
| File | Purpose |
|---|---|
scripts/scheduling/cli.py |
CLI entry point, argument parsing |
scripts/scheduling/engine.py |
Core occurrence logging and VDB section generation |
scripts/scheduling/drift_detector.py |
Drift status calculation for goals |
scripts/scheduling/goals.py |
Goal resolution, alias management, counter updates |
scripts/scheduling/pattern_learner.py |
Pattern learning from occurrences |
scripts/scheduling/calendar_optimizer.py |
Time slot suggestions |
scripts/scheduling/commitment_tracker.py |
Commitment follow-up tracking |
scripts/scheduling/validators.py |
Schema validation (occurrences, frequency fields, components) |
scripts/scheduling/sleep_gate.py |
Sleep gate check for high-intensity activities |
scripts/scheduling/config.py |
Timezone (America/Los_Angeles) and shared constants |
scripts/scheduling/activity_matcher.py |
Garmin activity to goal matching |
scripts/scheduling/rwgps_auto_logger.py |
RWGPS trip auto-logging |
scripts/scheduling/daily_planner.py |
Adaptive daily training planner (consumes the sleep gate) β see below |
scripts/scheduling/auto_curator.py |
Smart todo-console task scorer (lives here but serves the console, not goal scheduling) |
scripts/scheduling/sleep_guardian_learn.py, sleep_predictor.py, sleep_risk.py, sleep_guardian_backfill.py |
Predictive Sleep Guardian β documented on Sleep Guardian |
scripts/executive/gates.py |
Gate framework; is_action_blocked() is the canonical sleep-gate decision |
data/scheduling/occurrences-YYYY-MM.jsonl |
Occurrence records (monthly files) |
data/scheduling/patterns.json |
Learned day/time preferences |
data/scheduling/pending_confirmations.json |
Pending confirmation requests |
goals/active/*.json |
Goal definitions with frequency config |
data/assistant/commitments.json |
Commitment records |
CLI Commands
The CLI (scripts/scheduling/cli.py) provides these commands:
| Command | Description | Example |
|---|---|---|
log-occurrence |
Log a goal occurrence | --goal training --component skate --notes "Good session" [--force] |
delete-occurrence |
Soft-delete by ID | occ_a1b2c3d4 |
backfill |
Batch-log historical dates | --goal date '2025-12-15,2025-12-29' |
list-occurrences |
Debug listing | --goal training --since 2025-12-01 |
drift-status |
Check all goals | --format text --max-warnings 3 |
quiet-mode |
Toggle warnings | --goal date on/off |
schedule-suggest |
Get time suggestions | --goal date --days-ahead 14 |
add-commitment |
Create commitment | --goal date --date 2026-01-10 --text "Dinner at 7pm" |
commitment-status |
List commitments | --pending-only --format text |
close-commitment |
Mark done/missed | cmt_a1b2c3d4 --outcome done |
cleanup |
Maintenance tasks | --dry-run |
health |
System health check | (no args) |
Exit Codes
| Code | Meaning | Examples |
|---|---|---|
| 0 | Success | Occurrence logged, status retrieved |
| 1 | Expected error | Goal not found, duplicate occurrence, validation failure |
| 2 | Internal error | Lock timeout, file I/O error, unexpected exception |
Error Handling
Graceful Degradation
get_scheduling_section() is designed for VDB template integration and must never throw:
def get_scheduling_section(calendar_events: Optional[list] = None) -> dict:
"""
This function MUST NOT raise exceptions - it catches all errors
internally for graceful degradation in VDB generation.
Returns empty dict on any failure:
{ "drift_warnings": [], "suggestions": [], "commitments_today": [] }
"""
Lock Contention
If file lock cannot be acquired within 5 seconds:
- log_occurrence() raises RuntimeError
- CLI catches and returns exit code 2
- Suggest retry to user
In practice, lock contention is rare because: 1. Most operations are fast (sub-second) 2. Concurrent access is uncommon (usually one CLI or one skill at a time) 3. The retry loop polls every 100ms
Known Limitations and Improvement Opportunities
Current Limitations
-
No cross-goal awareness: The optimizer doesn't know if you already have a skating session scheduled when suggesting times for strength training. Each goal is optimized independently.
-
Pattern learning is simple: Uses raw occurrence counts with normalization. Doesn't account for recency (recent patterns should weight more) beyond the 90-day decay.
-
Week boundary edge cases: If
week_reset_houris 4am but user logs at 3am, occurrence goes to "previous" week. May be confusing. -
No timezone handling for travelers: All times are Pacific (
America/Los_Angeles). Someone traveling cross-country would get confusing drift calculations. -
Confirmation flow incomplete:
pending_confirmations.jsonexists but the confirmation UI/flow isn't fully implemented. -
Legacy format in occurrence files: Some old records use
datefield instead ofoccurred_at. The drift detector handles this, but it's technical debt.
Improvement Opportunities
-
Cross-occurrence conflict detection: Calendar conflicts are scored, but the optimizer doesn't check whether another goal already has an occurrence logged nearby. Avoid suggesting "Saturday morning" for skating if a cycling session is already logged.
-
Recency-weighted patterns: Recent occurrences should influence patterns more than old ones. Could use exponential decay in weight calculation rather than just post-hoc decay.
-
Natural language logging: Instead of
/schedule log training --component=skate, allow "I skated this morning" and infer goal+component. Would integrate well with the check-in flow. -
Streak tracking: Show current streak length and longest streak for motivation. The data exists, just needs aggregation logic and VDB integration.
-
Mobile-friendly drift notifications: Push notifications when goals hit WARNING status via Telegram proactive system, not just showing in daily brief.
-
Smart suggestions based on drift urgency: If a goal is OVERDUE, bump suggestion priority and narrow options to "this weekend only".
-
Component-level pattern learning for weekly goals: Currently patterns are per-component, but cross-component patterns (e.g., "always skates before strength") could improve suggestions.
-
Bulk import from external sources: One-time import of historical data from Strava, Apple Health, etc. to bootstrap patterns.
-
Goal dependencies: Allow "must do Zone 2 before max effort" constraints that affect both drift detection and suggestions.
-
Migrate legacy occurrence format: Write a migration script to convert old
datefield records to properoccurred_atformat with timezone. -
Add
sourcefield to occurrence schema for skill-written data: Currently skills write occurrences directly withdatefield. Should use engine.log_occurrence() or at minimum include properoccurred_at.
Integration Points
With Training Goals Skill
The training goals skill (.claude/skills/training-goals/) logs occurrences when activities are synced:
# When /log skating happens, the skill calls:
from scripts.scheduling.engine import log_occurrence
log_occurrence(
goal_id="...",
occurred_at=datetime.now(TZ),
source="manual",
component="skate_sessions",
notes="From check-in"
)
Note: log_occurrence() will check the sleep gate for high-intensity components. If 7-day sleep average is below 6.5h, logging max_effort_interval will raise a SleepGateError.
With VDB Daily Brief
The VDB template calls get_scheduling_section() to get:
- drift_warnings[] - Goals needing attention (limited to 2)
- suggestions[] - Recommended time slots (limited to 3)
- commitments_today[] - Things scheduled for today
With Precedent
When Precedent habits are logged, they can trigger occurrence logging via the training goals skill:
User logs "Skate" habit in Precedent
-> Precedent webhook/sync
-> Training goals skill detects skate habit
-> log_occurrence() called
-> Pattern updated, goal counter incremented
Testing
The system can be tested via CLI:
# Check health
python scripts/scheduling/cli.py health
# Log a test occurrence
python scripts/scheduling/cli.py log-occurrence training --component=skate --notes="Test"
# Check drift status
python scripts/scheduling/cli.py drift-status --format=text
# Get suggestions
python scripts/scheduling/cli.py schedule-suggest training --component=skate
# Cleanup (dry run)
python scripts/scheduling/cli.py cleanup --dry-run
The health check verifies: - Goals with frequency fields exist - Data directory is writable - Timezone can be loaded - File locking works - Occurrence file integrity (no corrupted JSON lines)
Adjacent Modules in scripts/scheduling/
Two more modules ship in this package but solve scheduling-adjacent problems rather than goal-cadence tracking. They are listed here so a reader browsing the directory isn't surprised by them.
Daily Planner (daily_planner.py)
An adaptive daily training planner. generate_plan(target_date=None) returns a structured plan dict built from current week progress vs. targets, sleep-gate status (it calls check_sleep_gate("bike_intervals") so intervals are flagged when the 7-day average is below 6.5h), readiness from Garmin data, days remaining in the week, and chronic component deficits. It runs as a module or CLI:
python -m scripts.scheduling.daily_planner # print plan
python -m scripts.scheduling.daily_planner --json # JSON output
python -m scripts.scheduling.daily_planner --write # write a markdown section to data/scratch/daily.md
Auto-Curator (auto_curator.py)
Scores and selects tasks for the smart todo-console view. It runs periodically (hourly during business hours), pulls tasks across projects via the Today API, tags the most actionable ones with auto for display on the console hardware, and logs curation decisions to data/auto-curator/curation.jsonl so the system can learn from engagement. This serves the console, not goal scheduling β it just happens to live in this package.
VitaScheduler vs. Scheduling Engine
These are two distinct systems with confusingly similar names.
| Scheduling Engine (this page) | VitaScheduler | |
|---|---|---|
| What it tracks | Frequency-based life goals (cadence) | Recurring background jobs |
| Code | scripts/scheduling/ |
api/src/scheduler.py (class VitaScheduler) |
| Runs where | On-demand (CLI, VDB, auto-loggers) | Long-lived asyncio tasks inside the API process |
| Unit | An occurrence of a goal | A named task loop |
| Entry point | /schedule, cli.py |
./run api start|stop|restart|status|log |
VitaScheduler is a single async scheduler running ~65 named background task loops (./run architecture-metrics --summary reports tasks=65). Loops fall into two shapes:
- Time-of-day jobs self-schedule to a target hour via
next_daily_target(e.g. VDB draft 4:45am,/sleep00:30, board Sun 6am, nightly rollup 00:05). - Interval jobs sleep a fixed delta between runs (media sync, garmin/cgm sync, briefing refresh every ~15min, etc.).
Tasks are launched in three groups β start_core_tasks / start_media_tasks / start_operations_tasks (api/src/scheduler_task_groups.py). Each run goes through run_task_with_policy (api/src/services/scheduler_supervision.py), which applies per-task failure policies. Any individual loop can be disabled via data/scheduler/disabled-tasks.json β a plain string array read by scripts/scheduler_config.py:is_task_enabled(task_name). Run history is appended to data/scheduler/executions-YYYY-MM.jsonl.
Durable freshness fallback
The Stream Dock full_sync button remains an explicit, button-only operation. It performs the expensive whole-device sequence: p2g, Garmin, RWGPS, CGM, RWGPS occurrence logging, stale memory-view rebuild, e-ink refresh, and auto-curation. Depending on that button for data freshness left Signal/RWGPS search content and memory views stale during travel or other periods when the dock was not pressed.
The freshness_fallback VitaScheduler loop (api/src/services/scheduler_freshness.py) is the durable backstop. It waits 10 minutes after API startup, checks hourly, and runs only legs whose freshness is unknown or strictly older than 8 hours:
| Leg | Freshness signal | Fallback action |
|---|---|---|
| Signal | Freshest of last_success, last_sync, or last_run in data/signal/sync_state.json |
Run scripts.signal_sync |
| RWGPS | Same timestamp selection in data/rwgps/sync_meta.json |
Run scripts/rwgps_sync.py |
| Search | last_reindex in data/sync/freshness_fallback.json |
Run the incremental search reindex |
| Memory views | Canonical view_loader --status --json --sync-sources verdict |
Run build_view.py --rebuild-stale if a rebuildable view is stale for more than 8 hours or has unknown age |
Signal no-op syncs stamp a successful empty_success state, so a quiet source remains provably fresh instead of repeatedly firing the fallback. The view leg is limited to the views the rebuild command can actually produce: health-snapshot, training-status, goal-progress, and identity-snapshot; deliberately unwired or retired views do not retrigger it forever. Signal and RWGPS run first, search reindexes after any successful sync leg, and views rebuild last so they see the newest source data. A view rebuild may run for up to 900 seconds because a real stale backlog can take several minutes.
This is intentionally not a scheduled full_sync: it does not run p2g, Garmin, CGM, occurrence logging, e-ink, or auto-curation. Per-leg outcomes (completed, failed, timeout, error, or skipped_fresh) and the last check/run are recorded in data/sync/freshness_fallback.json. Missing or unreadable timestamp markers count as overdue rather than silently fresh. The loop is also registered in api/src/loop_engine/registry.py and can be age-gated manually through POST /api/v1/scheduler/trigger/freshness_fallback.
Source-state vocabulary
Sync status files use scripts/sync/source_state.py so consumers can distinguish βthe source was checked and had nothingβ from βthe source has not run.β The writer enum is:
| State | Meaning |
|---|---|
healthy |
Successful run with a non-empty payload |
empty_success |
Successful run with zero records; freshness still depends on timestamp age |
auth_error |
Credential/permission failure; always treated as stale |
parse_error |
Data or another unclassified processing failure |
partial |
Some records succeeded and some failed |
never_synced |
No completed sync has been recorded; treated as stale |
stale |
Explicit stale state when a producer needs to record one |
is_stale is a separate age verdict based on last_success (falling back to last_sync) and the source-specific threshold, so source_state and freshness are related but not interchangeable. The nightly aggregator (scripts/sync/aggregate.py) additionally emits missing when an expected status file does not exist; missing is an aggregate/synthetic health state, not a SourceState enum value. Legacy files without taxonomy fields surface as never_synced, unless they contain a legacy error that can be classified as auth_error or parse_error.
Failure health vs. whole-loop liveness
Vita has two complementary scheduler checks:
- Per-task failure health β
scheduler_healthruns every 15 minutes and reads recent execution history. It alerts after consecutive failures according to the task's policy tier: 3 for tier A, 5 for tier B, and 10 for tier C.triggered,pending, andskippedrows do not count; asuccessorcompletedrow ends the failure streak. Alerts are consolidated through Telegram and bucket-deduplicated in six-hour windows. - Whole-loop liveness β the nightly
/sleepdata_source_healthstep adds a syntheticscheduler.looprow by inspectingdata/scheduler/executions-YYYY-MM.jsonl. This catches the distinct case where the scheduler loop itself wedges, so no task is running often enough to accumulate failure rows.
The liveness detector selects the most recently written monthly ledger, reads its last 256 KiB, ignores torn/garbage rows, and takes the maximum started_at because records are appended on completion and are not guaranteed to be timestamp-ordered. Its thresholds are intentionally distinct:
| Newest execution age | Result |
|---|---|
| At most 45 minutes | source_state: healthy, is_stale: false |
| More than 45 minutes and at most 120 minutes | healthy plus is_stale: true |
| More than 120 minutes | healthy plus is_stale: true and a WEDGED diagnostic |
| Ledger missing / empty / unreadable | Synthetic missing, empty_success, or parse_error; always stale |
This liveness leg is detection-only: it does not restart the API or scheduler, and because it runs inside nightly /sleep, it is not a real-time pager. The 45-minute stale threshold is above the measured legitimate inter-execution gap (~20.3 minutes); the 120-minute diagnostic threshold reserves βlikely wedgedβ for a darker failure. The separate 15-minute scheduler_health loop remains the real-time alert path for tasks that are still executing but repeatedly failing.
VitaScheduler has no dedicated page; the canonical recurrence/enqueue API lives in the Proactive Outreach reference, and the high-level placement is in Architecture.
Where to Go Next
- Goals β the goal schema and lifecycle these frequency goals plug into
- Sleep Guardian β predictive sleep-risk modules that share this directory
- Executive β the gate framework that owns the sleep-gate block decision
- Proactive Outreach β the enqueue/recurrence API used instead of cron
- Architecture β where VitaScheduler sits in the system