Scheduler Misfire Policy

Suppress a stale scheduled run when the process was down — and keep the missed occurrence visible in history

A missed occurrence is never silently dropped: set misfire_grace_seconds and a stale slot is recorded as missed in history instead of firing a no-longer-useful run.

Diagram

Within grace
or first run

Older than grace

⏰ Process boots
after downtime

🔎 Poll due jobs

📌 Slot age
vs grace?

🤖 Coalesce → one fire

📝 Record ‘missed’

Quick Start

  1. Set a grace window

    Set misfire_grace_seconds so a stale slot after downtime is recorded rather than fired.

    python
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleJob, Schedule
    
    agent = Agent(name="daily-brief", instructions="Summarise overnight activity.")
    
    job = ScheduleJob(
        name="daily-brief",
        schedule=Schedule(kind="cron", cron_expr="0 7 * * *", tz="UTC"),
        message="Brief last night's activity.",
        agent_id=agent.id,
        # If the process was down more than 10 minutes past 07:00, don't fire a
        # stale brief — record the miss instead.
        misfire_grace_seconds=600,
    )
  2. Same job in YAML
    yaml
    jobs:
      - name: daily-brief
        schedule:
          kind: cron
          cron_expr: "0 7 * * *"
          tz: UTC
        message: "Brief last night's activity."
        misfire_grace_seconds: 600
  3. Leave it unset for the old behaviour

    Leaving misfire_grace_seconds unset (or None) preserves the pre-existing behaviour — a missed occurrence still coalesces into one fire on recovery.

    python
    from praisonaiagents.scheduler import ScheduleJob, Schedule
    
    job = ScheduleJob(
        name="hourly-poll",
        schedule=Schedule(kind="every", every_seconds=3600),
        message="Poll upstream.",
        # misfire_grace_seconds=None  ← default; existing jobs unchanged
    )

How It Works

On recovery the store looks at each due job, computes the epoch of the occurrence it is firing for, and either fires (within grace / first run) or records missed (past grace).

Diagram
due.is_misfireScheduleStoreRunnerdue.is_misfireScheduleStoreRunneralt[within grace / first run / nograce][past grace]claim_due(now)for each due jobis_misfire(job, now)?Falsejob (fires once — coalesced)Trueadvance last_run_at past the slotlog_run(status=”missed”)[] (not fired)
Job statemisfire_grace_secondsSlot age at recoveryOutcome
Recurring, previously ranNone (default)anyCoalesces into one normal fire (unchanged from before)
Recurring, previously ran90≤ 90 sOne normal fire (within grace)
Recurring, previously ran90> 90 sRecorded as missed; not fired
First run (last_run_at is None)any graceanyAlways fires — a first fire is never a recovered recurrence
Anyany graceslot in future / not-yet-elapsedNot due; nothing to record
Anynon-numeric junk (hand-edited YAML)anyFalls back to “not a misfire” (no TypeError, other due jobs still fire)

What The User Sees

Silence, not a stale ping — a broken window shows up as a missed row in run history so an operator can spot the gap on the same page they read successes and failures.

python
history = store.get_history(job_id=job.id)
missed = [r for r in history if r.status == "missed"]

Kinds — how the “occurrence” is derived

scheduled_instant() derives the recovered slot per Schedule.kind, so you can reason about which fire is being suppressed.

KindThe slot being recovered forAge formula
everylast_run_at + every_seconds (the first slot that came due after the last run)now - (last_run_at + every_seconds)
cronThe last cron fire at or before nownow - croniter.get_prev(now)
atThe target instant itselfnow - at

Configuration Options

The one knob this feature introduces.

OptionTypeDefaultDescription
misfire_grace_secondsOptional[float]NoneMax age (seconds) of the occurrence being recovered for it to still fire. Older than this on recovery, the occurrence is recorded as missed instead of fired. Within the window (or a first run) coalesces into a single normal fire. None disables the check — existing jobs are byte-for-byte unchanged.

The new RunRecord.status value.

ValueMeaning
missedA recurring occurrence landed while the process was down and fell outside the job’s misfire_grace_seconds — the run was suppressed but recorded (not silently dropped) so an operator can see the gap.

Common Patterns

A daily brief tolerates a short delay but not a multi-hour one.

python
from praisonaiagents import Agent
from praisonaiagents.scheduler import ScheduleJob, Schedule

agent = Agent(name="daily-brief", instructions="Summarise overnight activity.")

job = ScheduleJob(
    name="daily-brief",
    schedule=Schedule(kind="cron", cron_expr="0 7 * * *", tz="UTC"),
    message="Brief last night's activity.",
    agent_id=agent.id,
    misfire_grace_seconds=600,
)

An interval poll with a wide grace fires after a brief restart but skips a slot after a multi-hour outage.

python
from praisonaiagents.scheduler import ScheduleJob, Schedule

job = ScheduleJob(
    name="hourly-poll",
    schedule=Schedule(kind="every", every_seconds=3600),
    message="Poll upstream.",
    misfire_grace_seconds=1800,
)

A first run always fires — the policy only applies to recovered recurrences.

python
from praisonaiagents.scheduler import ScheduleJob, Schedule

# Created after 07:00 with a tight grace — the first slot still runs once.
job = ScheduleJob(
    name="new-cron",
    schedule=Schedule(kind="cron", cron_expr="0 7 * * *", tz="UTC"),
    message="Initial fire.",
    misfire_grace_seconds=60,
)

Best Practices

Set the grace to the usefulness window

The grace is “how late can this slot still be useful?” — a daily 07:00 brief probably tolerates 10 minutes but not 6 hours; an hourly poll of live data might tolerate 30 seconds.

Leave it None when 'late is better than never'

None (the default) is the right choice when a coalesced late fire is still valuable — the previous behaviour is preserved byte-for-byte.

Read a cluster of missed rows as a downtime marker

A cluster of missed rows across jobs is a downtime marker — combine it with Scheduler Incidents so a broken schedule and a broken job are both surfaced.

Write a number, not a string

_coerce_optional_float will accept "3600" in a hand-edited config, but the intended form is unquoted (misfire_grace_seconds: 3600). Non-numeric junk fails safe to “no misfire policy”.


Related