Scheduler Incidents

Alert once when a scheduled job breaks, not on every tick — with recovery notes and threshold blip-tolerance

Turn a stream of scheduled-run failures into a single de-duped operator alert — one ping when a nightly job breaks, one when it recovers, nothing in between.

Diagram

✅ Success

❌ Failure

Yes

No / changed

⏰ Scheduled tick

📋 Result?

🟢 Recovery note
if incident was open

🔐 error_signature

🔒 Same signature
already alerted?

🤫 Silent — no re-alert

📣 Alert operator once

Quick Start

  1. Just enable it

    Incidents ride on top of the scheduler notepad, so the wrapper attaches the tracker automatically when you run from the scheduler — RunPolicy.alert_after_failures is the one knob you tune from the outside.

    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="nightly-brief",
        instructions="Summarise overnight repo activity.",
    )
    
    # Deliver runs to Slack; the wrapper attaches incident tracking automatically,
    # so a broken nightly job pings the channel once — not every night at 2 a.m.
    agent.start("Every day at 02:00, brief last-night activity to slack:#eng.")
    python
    from praisonaiagents import Agent
    from praisonai.scheduler import RunPolicy
    
    agent = Agent(
        name="nightly-brief",
        instructions="Summarise overnight repo activity.",
    )
    
    # Blip-tolerance without touching IncidentTracker directly.
    policy = RunPolicy(alert_after_failures=2)
  2. Drive ticks yourself

    Use the pure tracker when you drive ticks (custom runner, tests, out-of-process cron).

    python
    from praisonaiagents.scheduler import IncidentTracker
    from praisonaiagents.scheduler.models import RunRecord
    
    tracker = IncidentTracker()          # default: alert on the first failure
    state = {}                            # small dict — persist through JobStateStoreProtocol
    
    # Nightly job fails with the same 429 for three ticks in a row.
    alert = tracker.observe(RunRecord(job_id="nightly", status="failed", error="429 rate limit (req id 0xabc123)"), state)
    if alert:
        deliver_to_operator(f"⚠ Nightly job failing: {alert.error}")
    
    # Next two ticks: identical failure → no re-alert.
    tracker.observe(RunRecord(job_id="nightly", status="failed", error="429 rate limit (req id 0xdef456)"), state)  # None
    tracker.observe(RunRecord(job_id="nightly", status="failed", error="429 rate limit (req id 0x999abc)"), state)  # None
    
    # Next success closes the incident → single recovery note.
    resolved = tracker.observe(RunRecord(job_id="nightly", status="succeeded"), state)
    if resolved:
        deliver_to_operator("✅ Nightly job recovered")
  3. Tolerate transient blips
    python
    from praisonaiagents.scheduler import IncidentTracker
    
    # Alert only after the same failure repeats 3 ticks in a row.
    tracker = IncidentTracker(after_failures=3)

How It Works

The scheduler loads per-job state, runs the tick, folds the resulting RunRecord through the tracker, and delivers only when an alert is due.

Diagram
DeliveryTargetIncidentTrackerJobStateStoreSchedulerDeliveryTargetIncidentTrackerJobStateStoreScheduleralt[alert due][duplicate or below threshold]get_state(job_id)prior incident stateexecute tick → RunRecordobserve(record, state)Incident (state=”alerted”)send “⚠ job failing: <error>”None (silent)set_state(job_id, state)
Run statusTracker action
failed (new signature)Mints an incident; alerts once when threshold is met
failed (same signature)Advances counters; stays silent
succeeded (incident open)Resolves it; sends one recovery note
succeeded (nothing open)No-op
skipped / no_changeIgnored — neither failure nor recovery
missedIgnored — a suppressed occurrence is not a failure. See Misfire Policy.

Curious how this is wired end-to-end? See Scheduled Run Policy → Failure Alerting — Once Per Incident.


What Counts As “The Same” Failure

Failures collapse to one incident when their error_signature matches, and normalise_error strips only the volatile parts of a message before hashing.

Diagram

429 rate limit
(req id 0xabc123)

normalise_error
strip volatile ids

429 rate limit
(req id 0xdef456)

500 server error

normalise_error
different prefix

sha256 → signature A

sha256 → signature B

Incident A
alert once, dedupe

Incident B
new alert

Input token typeStripped?Why
Hex blobs (0xDEADBEEF) / long hex ids (>=8 chars)YesRequest ids, hashes — volatile per retry
Long digit runs (>=4 digits)YesTimestamps, epochs, pids, ports — volatile
Numbers fused into words (worker3, id42)YesIdentifier-like fragments — volatile
Short standalone codes (1–3 digits, e.g. 404, 500, exit 12)NoMeaningful category — different codes must re-alert
WhitespaceCollapsedCosmetic

The normaliser then keeps a prefix of the first 200 chars and sha256s it.

python
from praisonaiagents.scheduler import error_signature

# Same signature — only the request id changed.
assert error_signature("429 rate limit (req id 0xabc123def456)") == \
       error_signature("429 rate limit (req id 0x99900012345a)")

# Different signature — a status-code change must re-alert.
assert error_signature("HTTP 404 not found") != \
       error_signature("HTTP 500 server error")

Tolerating Transient Blips

Set after_failures=N to alert only when the same failure repeats N ticks in a row — a single flaky tick that recovers never pages you.

Diagram

threshold reached

success arrives
before threshold

success arrives

different signature

🟡 detected

🔴 alerted

🤫 blip — no alert, no recovery

🟢 resolved — recovery note

🔴 new incident
re-alert

python
from praisonaiagents.scheduler import IncidentTracker
from praisonaiagents.scheduler.models import RunRecord

tracker = IncidentTracker(after_failures=3)
state = {}

tracker.observe(RunRecord(job_id="poll", status="failed", error="boom"), state)  # None
tracker.observe(RunRecord(job_id="poll", status="failed", error="boom"), state)  # None
alert = tracker.observe(RunRecord(job_id="poll", status="failed", error="boom"), state)  # alerts now
assert alert.count == 3

Pick a threshold with this decision tree:

Diagram

Yes

No, transient blips OK

2 in a row

3+ in a row

Is a single failed
tick worth waking me?

IncidentTracker
default: after_failures=1

How many ticks
before I care?

IncidentTracker after_failures=2

IncidentTracker after_failures=3


Wiring the Alert

The returned Incident carries the raw error for the alert body; hand it to the job’s existing delivery target — there is no new delivery API.

python
from praisonaiagents.scheduler import IncidentTracker
from praisonaiagents.scheduler.models import RunRecord

tracker = IncidentTracker()
state = {}

def deliver_to_operator(text):
    ...  # reuse the job's DeliveryTarget (e.g. slack:#eng)

record = RunRecord(job_id="nightly", job_name="Nightly", status="failed", error="429 rate limit")
incident = tracker.observe(record, state)
if incident:
    deliver_to_operator(f"⚠ {incident.job_name} failing: {incident.error}")

Recovery Notes

On the next success after an alerted incident, the wrapper delivers a single recovery note through the same channel:

✅ Scheduled job '<name>' recovered.

The recovery note is only sent when deliver_on_failure is on — it is the mirror of the failure alert.


Configuration Options

IncidentTracker takes one option.

OptionTypeDefaultDescription
after_failuresint1Alert only after this many consecutive failures of the same signature. Values below 1 are treated as 1.

Each incident is a small dataclass persisted per job.

FieldTypeDefaultDescription
job_idstr—ID of the failing job.
signaturestr—Stable error_signature grouping the failures.
stateLiteral["detected","alerted","resolved"]"detected"Lifecycle state.
errorOptional[str]NoneMost recent raw error message (for the alert body).
first_seenfloattime.time()Epoch seconds of the first failure.
last_seenfloattime.time()Epoch seconds of the most recent failure.
countint0Number of failing ticks grouped into this incident.
job_namestr""Human-readable job name for display.

Common Patterns

Persist tracker state through the store you already use for the notepad — the incident dict round-trips as-is.

python
from praisonaiagents.scheduler import IncidentTracker
from praisonaiagents.scheduler.models import RunRecord

tracker = IncidentTracker()

def tick(store, record):
    state = store.get_state(record.job_id)   # JobStateStoreProtocol
    incident = tracker.observe(record, state)
    store.set_state(record.job_id, state)    # incident lives inside per-job state
    return incident

Reuse the job’s delivery target for the alert — no second channel.

python
incident = tracker.observe(record, state)
if incident:
    job.delivery.send(f"⚠ {incident.job_name} failing: {incident.error}")

Raise the threshold for a chatty external API so occasional 429s do not page you.

python
tracker = IncidentTracker(after_failures=3)

Best Practices

Reuse the job's delivery target

Send incident alerts through the same DeliveryTarget the job already uses — don’t set up a second channel.

Keep after_failures low for daily jobs

Use 1 or 2 for hourly/daily jobs. A high threshold delays detection because it needs that many consecutive ticks first.

Keep the tracker at the runner level

Don’t call IncidentTracker from inside an agent tool — it’s a runner-level concern. The scheduler wraps it for you.

Round-trip state through the existing store

Persist incident state through the same store you use for the job-state notepad, not a new one.


Related