Skip to content

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:

  1. If no sleep data exists: warn but don't block
  2. If 7-day average < SLEEP_THRESHOLD (6.5 hours): block the occurrence with SleepGateError
  3. 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
  • /checkin sessions - Status reported at start
  • /log commands - 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:

  1. Source validation: Must be one of manual, checkin, garmin, rwgps
  2. Component resolution: For weekly goals, resolves aliases to canonical names
  3. 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. Raises SleepGateError if blocked. Can be bypassed with bypass_sleep_gate=True.
  4. Duplicate detection:
  5. Same garmin_activity_id or rwgps_trip_id
  6. Same goal+component within 2-hour window
  7. Same goal+component+date for manual entries (unless skip_date_dedup=True)
  8. Atomic file writing: Uses fcntl.flock() with 5-second timeout
  9. Pattern updates: Triggers pattern learning after successful log
  10. Counter updates: Increments current_value on 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:
  • id must match occ_ + 8 hex chars
  • source must be in VALID_SOURCES (manual, checkin, garmin, rwgps)
  • goal_id must be valid UUID
  • occurred_at must be ISO-8601 with timezone offset
  • v must 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 components dict with target values
  • 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

  1. 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.

  2. 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.

  3. Week boundary edge cases: If week_reset_hour is 4am but user logs at 3am, occurrence goes to "previous" week. May be confusing.

  4. No timezone handling for travelers: All times are Pacific (America/Los_Angeles). Someone traveling cross-country would get confusing drift calculations.

  5. Confirmation flow incomplete: pending_confirmations.json exists but the confirmation UI/flow isn't fully implemented.

  6. Legacy format in occurrence files: Some old records use date field instead of occurred_at. The drift detector handles this, but it's technical debt.

Improvement Opportunities

  1. 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.

  2. 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.

  3. 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.

  4. Streak tracking: Show current streak length and longest streak for motivation. The data exists, just needs aggregation logic and VDB integration.

  5. Mobile-friendly drift notifications: Push notifications when goals hit WARNING status via Telegram proactive system, not just showing in daily brief.

  6. Smart suggestions based on drift urgency: If a goal is OVERDUE, bump suggestion priority and narrow options to "this weekend only".

  7. 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.

  8. Bulk import from external sources: One-time import of historical data from Strava, Apple Health, etc. to bootstrap patterns.

  9. Goal dependencies: Allow "must do Zone 2 before max effort" constraints that affect both drift detection and suggestions.

  10. Migrate legacy occurrence format: Write a migration script to convert old date field records to proper occurred_at format with timezone.

  11. Add source field to occurrence schema for skill-written data: Currently skills write occurrences directly with date field. Should use engine.log_occurrence() or at minimum include proper occurred_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, /sleep 00: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:

  1. Per-task failure health β€” scheduler_health runs 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, and skipped rows do not count; a success or completed row ends the failure streak. Alerts are consolidated through Telegram and bucket-deduplicated in six-hour windows.
  2. Whole-loop liveness β€” the nightly /sleep data_source_health step adds a synthetic scheduler.loop row by inspecting data/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