Scheduler Monitor

Wake a scheduled agent only when a watched source changed β€” and tell you only what changed

Turn a scheduled agent into a monitor: probe a cheap source each tick, run the model only when it changed, and stay silent when it didn’t.

Diagram

No

Yes / first run

⏰ Tick due

πŸ”Ž Probe source

πŸ” Changed?

🀫 no_change β€” silent

πŸ“ Seed diff into message

πŸ€– Run model once

Quick Start

  1. Turn an agent into a monitor

    Add monitor to a ScheduleJob. The agent runs only when the watched URL changes.

    python
    from praisonaiagents import Agent
    from praisonaiagents.scheduler import ScheduleJob, Schedule
    
    agent = Agent(name="News watcher", instructions="Summarise new items")
    
    job = ScheduleJob(
        name="hn-frontpage",
        schedule=Schedule(kind="every", every_seconds=900),
        message="Summarise the changes.",
        agent_id=agent.id,
        monitor={"url": "https://news.ycombinator.com/"},
    )
  2. Watch a shell source instead

    Use command inside monitor to watch any cheap shell output β€” a file, a database count, a health probe.

    python
    from praisonaiagents.scheduler import ScheduleJob, Schedule
    
    job = ScheduleJob(
        name="disk-watch",
        schedule=Schedule(kind="every", every_seconds=300),
        message="Explain the change in disk usage and flag anything urgent.",
        monitor={"command": "df -h /"},
    )

What The User Sees

Silence is the feature β€” an unchanged source produces no ping and spends no tokens.

Diagram
UserAgentMonitor gateSourceUserAgentMonitor gateSourceloop[Unchanged tick]nothing deliveredprobe (cheap)same hashno_change β€” silent (0 tokens)probe (cheap)new hash + diffrun message + seeded diffdelivered summary of what changed

Three Outcomes

Every tick records exactly one status.

Diagram

First run / changed

Unchanged

Probe error

⏰ Tick

πŸ”Ž Probe source

Changed?

βœ… succeeded

πŸ”• no_change

❌ failed

StatusMeaning
succeededThe source changed (or first run) β€” the agent ran and delivered
no_changeA watched source was unchanged β€” suppressed silently, no tokens, no delivery
failedThe probe itself errored
missedA recurring occurrence landed while the process was down and fell outside misfire_grace_seconds β€” recorded instead of fired. See Misfire Policy.
abandonedThe run was claimed but never completed (crash, OOM, or restart mid-run). A one-shot is retried once; a recurring job waits for its next slot. See Crash Recovery.

no_change is distinct from skipped (a generic gate go/no-go) so you can tell “nothing changed” apart from “the gate said don’t run”.


How It Works

Each tick probes the source, hashes the output, and compares it to the last-seen hash held in per-job state.

Tick outcomeWhat happensRecorded status
Source unchangedModel turn suppressed β€” no tokens, no deliveryno_change
Source changedBounded diff seeded into message, model runs oncesucceeded
First run (no prior state)Treated as changed β€” model runs, hash storedsucceeded
Probe erroredPrior state left untouched so a later recovery still suppressesfailed

Configuration Options

monitor spec

monitor is a small mapping naming one cheap source to probe each tick.

KeyTypeDescription
commandstrShell command whose stdout is hashed and diffed each tick
urlstrA bounded fetch whose response body is hashed and diffed each tick

ScheduleJob field

FieldTypeDefaultDescription
monitorOptional[Dict[str, Any]]NoneChange-detection source spec. None keeps the existing stateless behaviour. Omitted from serialization when unset (fully backward-compatible)

GateResult β€” monitor outcome

The wrapper’s monitor gate returns a GateResult carrying the monitor-mode outcome.

FieldTypeDefaultDescription
runboolTrueWhether to spend the model turn this tick
context`str \None`NoneText seeded into the job message when run is True
reason`str \None`NoneHuman-readable note recorded with the run
no_changeboolFalseTrue when the watched source was unchanged β€” implies run=False (enforced in __post_init__)
state_updates`dict \None`NoneBounded key/value to persist for the next tick (last-seen hash, watermark). None means “write nothing”

Setting no_change=True always forces run=False, so a monitor can never fire a tick the contract defines as suppressed.

python
from praisonaiagents.scheduler import GateResult

# unchanged source β†’ suppress silently
GateResult(no_change=True, state_updates={"last_hash": "abc123"})

# changed source β†’ run with a diff seeded into the message
GateResult(run=True, context="2 new releases", state_updates={"last_hash": "def456"})

RunRecord.status

ValueMeaning
succeededThe model turn ran and completed
failedThe run raised or the probe errored
skippedA stateless gate said don’t run
no_changeA watched source was unchanged (monitor mode)
missedA recurring occurrence expired past misfire_grace_seconds β€” recorded, not fired
abandonedA claimed run’s lease expired without completing (crash or restart mid-run) β€” see Crash Recovery

Crash Recovery for In-Flight Runs

A run lost to a crash is recorded as abandoned and retried according to the job kind β€” no configuration needed.

Diagram

Completes

Crash / restart

One-shot, first crash

Recurring

⏰ claim_due

πŸ€– Agent runs

βœ… mark_run β€” lease released

πŸ’₯ Lease expires

πŸ“ abandoned row

πŸ” Retried once

⏭️ Next slot

Job kindFirst abandoned runRetry abandoned too
One-shot (once=True / delete_after_run) or max_runs metRe-offered once (at-least-once across crashes)Retired β€” two abandoned rows in history
Recurring (every, cron)Waits for the next slot (at-most-once per slot)Same on every slot
Spent job whose until deadline passed meanwhileRetired, not re-offeredβ€”

A spent job is no longer deleted when it is claimed β€” it stays leased and is removed only once its run completes. A run that is still in flight in the same process is never re-offered by that process, even if it outlives its lease, so it cannot fire twice.


URL Monitor Reliability & Safety

Every URL probe runs behind a bounded, hardened path. These are the guarantees an operator can rely on before pointing a monitor at anything they don’t own end-to-end.

Diagram

no

yes

no

yes

πŸ”Ž Probe URL

scheme
http/https?

❌ Rejected

🌐 Resolve DNS

every address
public?

❌ Rejected

πŸ“Œ Pin to validated IP

⏱️ Total-deadline watchdog

⬇️ Fetch ≀ 64 KiB

πŸ” Hash + diff

GuaranteeValue
Total wall-clock deadline (connect + body read)30 s (MonitorGate(timeout=…))
Response body cap64 KiB β€” anything larger is rejected
Retained preview used for diffing2048 B
Diff seeded into the agent messageup to 8000 chars
Schemes allowedhttp, https only
Embedded credentials (https://user:pass@…)rejected before any connect
Private / loopback / link-local addressesrejected before any connect
Connection pinningfixed to the validated IP β€” a DNS re-resolve can’t smuggle in a private IP
TLSfull hostname + CA verification
Redirectsnot followed

Monitor vs Pre-Run vs Command

Three scheduler features feel similar but solve different problems.

Diagram

Silence unless a
watched source changed

Skip when there’s
nothing to do

Run a command
instead of the model

What do you want
on each tick?

monitor β€” stateful

pre_run β€” stateless gate

command β€” model-free action

FeatureKindFires the model?Silence when…
monitorStateful (remembers last hash)Only when the source changedThe source is unchanged (no_change)
pre_runStateless go/no-go gateOnly when the gate says “go”Nothing to do (skipped)
commandModel-free delivery actionNever β€” delivers stdout verbatimβ€”

From the SDK docstring: “monitor is distinct from pre_run (a stateless go/no-go gate) and command (a model-free delivery action).”


Per-Job State

A monitor needs memory across wake-ups β€” the last-seen hash or a watermark. The core defines the JobStateStoreProtocol contract, and the concrete ConfigYamlScheduleStore implementation now ships (PR #4057) with a 16 KiB per-job cap. The executor auto-injects a “Notepad” block into the prompt and persists GateResult.state_updates on go and no_change ticks alike.

MethodDescription
get_state(job_id)Return the persisted state dict ({} if none)
set_state(job_id, state)Persist bounded state for the job
clear_state(job_id)Drop state when the job is removed

See Cross-Run Memory for the full notepad walkthrough β€” the format, how the agent writes back, and the one-shot-job caveat.


CLI, YAML, and Python Parity

The same monitor spec round-trips through every front-end.

Python

python
from praisonaiagents.scheduler import ScheduleJob, Schedule

job = ScheduleJob(
    name="release-watch",
    message="Summarise what changed.",
    schedule=Schedule(kind="every", every_seconds=3600),
    monitor={"url": "https://example.com/releases"},
)

YAML

yaml
jobs:
  - name: release-watch
    message: "Summarise what changed."
    schedule:
      kind: every
      every_seconds: 3600
    monitor:
      url: "https://example.com/releases"

ScheduleJob.to_dict() / from_dict() only persist monitor when it’s configured, so stateless jobs stay byte-for-byte unchanged.


Best Practices

Bound your source so a large fetch can't stall the tick

The probe runs on every tick. Watch a small, cheap source β€” a status endpoint, a row count, a file’s modification marker β€” not a multi-megabyte page. A bounded source keeps the ticker responsive.

python
monitor={"command": "curl -sf --max-time 5 https://status.example.com/health"}
Use monitor when you want silence-when-unchanged

Reach for monitor when the whole point is “only tell me when something changed” β€” a page, a feed, a metric. Unchanged ticks cost zero tokens and deliver nothing.

Use pre_run when you want silence-when-nothing-to-do

If the decision is a stateless go/no-go (an inbox check, a queue depth), use pre_run instead β€” it doesn’t need to remember a prior hash.

Let first-run seed the baseline

The first tick has no prior state, so it always runs and stores the baseline hash. Expect one delivery when a monitor job is created, then silence until the source moves.

Bound a monitor that watches for a one-off event

Recurring monitors can be bounded β€” see max_runs and until β€” so a change-detection job that watches for a one-off event can retire itself instead of ticking forever.

Watch no_change separately from skipped

Filter run history on no_change to see “how often was the source unchanged” without conflating it with a generic gate skip.

Leave prior state on a probe error

Return state_updates=None on an error probe so prior state is untouched β€” a later recovery to the prior output still suppresses correctly.

Keep stored state bounded

State is a small scratchpad, not a database. Store cursors and hashes, not full payloads, so a runaway write can’t grow unbounded.

Prefer a small endpoint over a full page for URL monitors

Each URL probe is capped at a total wall-clock deadline (default 30 s) covering connect and body read, so a slow-drip attacker can no longer stall the scheduler tick. A legitimate page you don’t need still costs a fetch on every tick, though β€” prefer a small JSON endpoint over a full page and the whole loop stays cheap.


Related