-
Notifications
You must be signed in to change notification settings - Fork 0
Scheduling Triggers
← Wiki home · Rendered viewer with search
The scheduling module answers one question repeatedly: should this loop run right now? Without it, the schedules block of a loop config parses and validates and then nothing ever fires. Two files split the work:
| File | Responsibility |
|---|---|
runtime/crates/loopsmith-cli/src/schedule.rs |
Trigger evaluation (cron parsing, mtime scanning, edge detection) and generation of OS scheduler artifacts |
runtime/crates/loopsmith-cli/src/cmd/schedule.rs |
The loopsmith schedule command — picks the right scheduler for this machine and prints or installs the artifact |
The consumer of the evaluation half is loopsmith watch (src/cmd/watch.rs), which constructs a Watcher, sizes its sleep with poll_interval, and calls poll in a loop.
graph TD
W["loopsmith watch"] --> Watcher
Watcher -->|"poll()"| Fired["Vec<Fired>"]
Fired --> Run["run the loop"]
S["loopsmith schedule"] --> Detect["Platform::detect()"]
Detect --> launchd_plist
Detect --> schtasks_command
Detect --> crontab_line
launchd_plist --> W
schtasks_command --> W
crontab_line --> Run
Note the asymmetry at the bottom: launchd and Task Scheduler are handed watch — one long-lived process that evaluates triggers itself — while a crontab line is handed run, a single iteration, because cron is itself the trigger evaluator in that arrangement. That distinction shows up again in crontab(), which falls back to @reboot when the config has no cron trigger for cron to act on.
CronExpr::matches is evaluated against UTC, and the module says so in its own header. Deriving a local offset in a multithreaded Unix process is unsound without care, and a scheduler that is quietly an hour off twice a year is worse than one that is honestly in UTC. The crontab path prints a warning about this, because there the expression is interpreted by cron, in local time — a genuine discrepancy that the user needs to know about. For a cadence that shouldn't care about wall clocks at all, Trigger::Interval is the right answer.
Rather than pull in a date crate, the module carries civil_from_unix, a direct port of Howard Hinnant's civil_from_days, producing a Civil { year, month, day, hour, minute, weekday }. The weekday falls out of (days + 4).rem_euclid(7) because 1970-01-01 was a Thursday. This function has one out-of-module consumer: logging::format_utc uses it to stamp log lines.
CronExpr::parse takes the standard five fields — minute hour day-of-month month day-of-week — and delegates each to parse_field(spec, min, max), which returns a Field:
pub enum Field {
Any, // `*`
Values(Vec<u32>), // sorted, deduplicated
}parse_field handles comma lists, a-b ranges, and /step suffixes in any combination (*/15, 9-17, 1,15). Because Values is kept sorted and deduplicated, Field::matches is a binary search.
Errors are strings that name the offending token: a step of 0 is rejected explicitly ("step of 0 would never fire") rather than looping forever, and out-of-range or inverted ranges report the bound they violated. Day-of-week accepts 0..=7 and CronExpr::matches treats 7 as Sunday when the computed weekday is 0, matching every other cron.
CronExpr::parse returns Result<Self, String> and Watcher::poll swallows the error with let Ok(c) = ... else { continue } — a malformed expression that survived config validation makes its trigger inert rather than killing the watcher.
Watcher holds the state that makes triggers edges rather than levels. All four maps exist to answer "have I already seen this?":
pub struct Watcher {
fired_minute: BTreeMap<String, i64>, // cron expr → minute-of-epoch
last_interval_run: BTreeMap<u64, i64>, // period → last fire
last_mtime: BTreeMap<String, u64>, // watched path → last seen mtime
satisfied_seen: BTreeMap<String, bool>,// goal → last known satisfaction
ignore: Vec<String>, // extra dirs to skip
}poll(triggers, root, now, satisfied) returns Vec<Fired> — every trigger that came due on this tick. Each variant has its own definition of "due":
-
Trigger::Cron— the expression matches the currentCivilandfired_minutedoes not already hold this minute-of-epoch. The watcher polls faster than once a minute, so without this guard a* * * * *entry would fire on every poll inside the minute. -
Trigger::Interval— the firstpollprimeslast_interval_runand fires nothing; subsequent polls fire oncenow - prev >= seconds. Starting a watcher does not immediately kick off a run. -
Trigger::FileChange— comparesnewest_mtime_ignoring(root.join(path))against the last recorded value, firing only on a strict increase. A path with no previous entry records the current value and stays quiet. -
Trigger::GoalSatisfied— fires on thefalse → truetransition only. Still-satisfied is not a new edge. -
Trigger::Manual— never fires from the watcher.
Watcher::prime(triggers, root) records the current mtime for every FileChange trigger without firing, so starting the watcher does not look like a change. Watcher::ignoring(ignore) is the constructor watch.rs actually uses, passing the success-export directory name.
newest_mtime_ignoring walks a path (depth-limited to 24) and returns the newest mtime in seconds, reporting 0 for a missing path — a watched path that does not exist yet is a normal state, not an error.
The skip list is where the real design pressure is:
const NEVER_WATCHED: [&str; 3] = ["state", ".git", "logs"];Every one of these is written by a run. A watcher that noticed them would fire a run, which would write them, which would fire a run — a loop that is never idle again, with nothing in the output explaining why. state/ is sled's directory; logs/ holds the run log, written on every single iteration.
The extra parameter covers the case only the caller knows: the success export, whose directory is named after the loop and is written whenever a run meets its bar. That one is the same mistake with a longer fuse — the loop would cycle permanently exactly when it succeeded. Both cases are pinned by tests (the_watcher_ignores_its_own_state_directory, the_watcher_ignores_the_run_log_and_the_success_export), the second of which also asserts that an ordinary edit still registers, so the skip list can't quietly render the trigger inert.
poll_interval(triggers) returns the shortest sensible sleep for a trigger set, starting at 30s and tightening per trigger:
| Trigger | Ceiling | Why |
|---|---|---|
Cron |
20s | Sub-minute polling, or a minute can be missed entirely |
FileChange |
5s | Responsiveness to edits |
Interval { seconds } |
seconds / 4, floor 1 |
Sample well inside the period |
Manual |
— | No constraint |
The result is clamped to at least 1s.
Fired::describe() renders the reason for the run log — "cron * * * * * matched (UTC)", "src/ changed", and so on. It is the only place the why of a run becomes user-visible text.
schedule.rs generates three artifacts as plain strings; none of them touch the filesystem:
-
launchd_plist(label, exe, config, log_dir)— aRunAtLoad+KeepAliveagent invokingloopsmith watch <config>, with stdout and stderr pointed at the log dir. -
crontab_line(exe, config, expr, log_dir)—<expr> <exe> run <config> >> <log_dir>/loopsmith.log 2>&1. -
schtasks_command(label, exe, config)— aschtasks /Create /F /RL LIMITED /TN "<label>" /SC MINUTE /MO 1 /TR ...invocation./Fso re-running updates the task instead of clashing on the name;/RL LIMITEDbecause a loop has no business running elevated.
The schtasks quoting is the fiddly part and has a test of its own. /TR takes the entire command as a single argument, so the paths inside it need a second level of escaping (\") — C:\Program Files\… is the case that finds out whether it's there. The test also pins that the output is exactly one line, since pasting it into a shell is the whole delivery mechanism.
default_label(loop_name) produces com.loopsmith.<sanitized>, replacing every non-alphanumeric ASCII character with - so the label is safe for launchd.
launch_agents_dir() resolves ~/Library/LaunchAgents via loopsmith_util::platform::home_dir, but LOOPSMITH_LAUNCH_AGENTS_DIR overrides it. That override exists for the test suite: installing a launch agent is a persistent, user-visible change, and a suite that makes one into a real home directory is a suite nobody can run twice.
cmd/schedule.rs::execute(config, install) loads the config, resolves the absolute config path and current exe, creates <root>/logs, and dispatches on Platform::detect().scheduler().
The dispatch key is what is installed, not what the OS is famous for. cfg!(target_os = "macos") tells you the build target had launchd; it does not tell you this machine has launchctl on PATH, and a container built FROM debian has neither crontab nor systemctl unless someone put them there. Printing a crontab line to a host with no cron is an instruction that silently does nothing.
When no scheduler is found, execute errors with the list of candidates that were actually probed — obtained from preferred_names, which re-reads loopsmith_util::platform::preferred_schedulers(p.os) rather than restating the list. The version that restated it told a Windows user to install crontab. The error also points at the fallback: loopsmith watch under any process supervisor — a systemd unit, a container restart policy, or a terminal left open.
The log directory is <config_dir>/logs, deliberately not state/. state/ is sled's, and the watcher ignores everything inside it — including, until this was moved, the OS's own record of why the loop failed to start.
Only one of the three branches has somewhere safe to write:
| Scheduler |
--install behavior |
|---|---|
launchctl |
Writes <LaunchAgents>/<label>.plist, then prints the launchctl load -w command for the user to run |
schtasks |
Prints the command; calls nothing_to_install — Task Scheduler's jobs live in a database reachable only through schtasks
|
| crontab / other | Prints the line; calls nothing_to_install — a crontab is one file per user with no drop-in directory, so writing to it means rewriting entries this loop did not put there |
nothing_to_install(reason) exists so a flag that cannot do anything says so on stderr, instead of being silently accepted. A no-op flag is worse than one that explains itself: the user walks away believing something was installed.
Even in the launchd case, the module stops one step short. Writing the plist is reversible; launchctl load -w starts a process that survives reboots. That step stays the user's call, for the same reason schtasks prints rather than executes.
-
Adding a trigger kind means a new
Triggervariant inloopsmith-core, a match arm inWatcher::poll, a matchingFiredvariant with adescribe()string, whatever state it needs onWatcher, and a case inpoll_intervalif it constrains cadence. Ask whether the new trigger is an edge or a level — every existing one is an edge, andpollis called far more often than the thing it watches changes. -
Anything a run writes must be unreachable from
newest_mtime_ignoring, either inNEVER_WATCHEDor through theextralist. This is the module's recurring failure mode and the reason two of its tests read like incident reports. -
Cron changes should keep
parsetotal over hostile input; themalformed_expressions_are_rejected_with_a_reasontest enumerates the shapes that must stay rejected,*/0among them. -
New scheduler backends belong behind
Platform::scheduler()andpreferred_schedulersinloopsmith-util, so the probe and the error message keep sharing one list.
Generated by gitnexus wiki from bitphill/loopsmith. Do not edit by hand — regenerated on each run.
- CLI Command Surface
- Distribution & Installers
- Evolution & Perturbation
- Example Loops
- Execution Graph & Concurrency Planning
- Gating & Success Criteria
- Loop Configuration Schema
- Loop Execution Engine
- Markdown/YAML Config Interchange
- MCP Server
- Memory & Episode Store
- Other
- Permissions & Sandboxing
- Platform Utilities
- Project Documentation
- Provider Integration
- Scheduling & Triggers
- Skills System