Scheduled Tasks
August 16, 2026 · View on GitHub
Rela includes a built-in task scheduler that runs Lua scripts on recurring schedules. This lets you automate recurring work — reports, validation checks, data cleanup — without depending on external cron or task scheduling infrastructure.
Quick Start
1. Create a script
Create a Lua script in your project's scripts/ directory:
-- scripts/daily-check.lua
local orphans = rela.list_entities("*", "status=draft")
local lines = {}
for _, e in ipairs(orphans) do
table.insert(lines, "- " .. e.type .. "/" .. e.id .. ": " .. (e.properties.title or "(no title)"))
end
if #lines > 0 then
local body = "## Draft Entities\n\n" .. table.concat(lines, "\n")
rela.update_entity("REPORT-daily-check", {
title = "Daily check: " .. #orphans .. " draft entities",
status = "open",
}, body)
end
2. Define a schedule
Create schedules.yaml in your project root:
tasks:
- name: daily-check
script: daily-check.lua
every: day
3. Start the scheduler
rela scheduler
The scheduler runs in the foreground, executing tasks as they become due. Stop it with Ctrl+C or SIGTERM.
Configuration
Schedules are defined in schedules.yaml in the project root. Each task has a name,
a script path (relative to scripts/), and a schedule.
tasks:
- name: daily-report
script: reports/daily.lua
every: day
- name: weekly-review
script: checks/weekly.lua
every: friday
- name: quick-check
script: checks/orphans.lua
every: 30m
Identity and what a task can read (run_as)
A scheduled task runs under an identity, and that identity decides what
its script may read. By default every task runs as system:scheduler, a
fixed identity that does not depend on which OS account started the
scheduler. run_as gives a task its own identity instead:
tasks:
- name: weekly-digest
script: reports/digest.lua
every: monday
run_as: system:digest # an identity, not a permission
run_as grants nothing by itself. Privileges come from acl.yaml, the
same place every other principal's do:
# acl.yaml
roles:
reporting:
read: [ticket, project]
assignments:
system:digest: reporting
With that pairing, rela.get_entity / list_entities / search /
get_relations and the trace bindings inside digest.lua return only what
the reporting role may see. Anything else is invisible to the script —
which is what bounds the data an AI-assisted or outbound-reporting job can
possibly include.
If your project has an acl.yaml, grant the scheduler
The default identity is subject to the same rule as any other: it reads
only what a role grants it. A project that has an acl.yaml but never
assigns system:scheduler a role has tasks that read nothing — and
because a gated read is indistinguishable from missing data, they fail
silently rather than erroring.
rela migrate adds the grant for you:
# acl.yaml
roles:
scheduler-system:
read: ["*"]
assignments:
system:scheduler: scheduler-system
Narrow read: to the types your jobs actually need, or give each job its
own run_as identity with a tighter role — the migration writes a
permissive default so existing jobs keep working, not because wide access
is recommended.
Projects with no acl.yaml need do nothing: with no policy there is no
access control, and scheduled tasks read the whole graph as they always
have.
Notes:
- An identity with no assignment reads nothing. If
run_asnames a principal thatacl.yamlnever assigns a role, the task's reads come back empty. A typo produces a silently empty job, so check the identity against your assignments when a task stops finding data. - Field-level redaction does not apply to scheduled tasks yet. Row-level
access is enforced (an entity your identity cannot read stays invisible),
but
visible:field policy is not applied on this path — a task that may read an entity type receives all of its properties. Do not rely on field policy to hide values from a scheduled script. - Writes are unaffected: they go through the normal ACL, exactly as before.
Schedule Values
| Value | Meaning |
|---|---|
day | Once per day — runs after local midnight |
monday | Once per week on Mondays (after midnight local time) |
friday | Once per week on Fridays |
week | Alias for monday |
30m | Every 30 minutes |
2h | Every 2 hours |
1h30m | Every 90 minutes (any valid Go duration) |
15 | Every 15 minutes (bare number = minutes) |
All seven weekday names are supported: monday, tuesday, wednesday, thursday,
friday, saturday, sunday.
Day and weekday schedules check whether the calendar boundary has been crossed since the last run. They don't fire at a specific clock time — they fire on the first scheduler tick after the target day begins. This means "every friday" runs as soon as possible after Friday midnight, regardless of when you start the scheduler.
Interval schedules fire when enough time has elapsed since the last run. A 30m task
that last ran at 9:05 will next run at or after 9:35.
Task Names
Each task must have a unique name. The name is used to track execution state — if you rename a task, it will be treated as a new task and execute immediately on next startup.
Script Paths
Script paths are relative to the scripts/ directory. Subdirectories are supported:
tasks:
- name: daily-report
script: reports/daily.lua # scripts/reports/daily.lua
- name: cleanup
script: maintenance/cleanup.lua # scripts/maintenance/cleanup.lua
Execution Model
Sequential Execution
Tasks execute sequentially in the order they appear in schedules.yaml. If you have
three tasks due at the same time, they run one after another — never in parallel. This
means:
- No race conditions between scripts modifying the same entities
- Predictable resource usage
- Simple mental model — each script sees the results of the previous one
Workspace Sync
Before each task execution, the scheduler syncs the workspace from disk. This ensures scripts always see the latest entities and relations, even if files were modified externally (by another tool, a git pull, or the data entry app).
Script Capabilities
Scheduled scripts have the same capabilities as rela script:
- Entity CRUD:
rela.create_entity(),rela.update_entity(),rela.delete_entity() - Graph queries:
rela.list_entities(),rela.get_relations(),rela.trace_from(),rela.trace_to() - AI access:
ai.chat(),ai.complete()(requires.rela/ai.yaml) - Output:
rela.output()(logged to stderr) - File writing:
rela.write_file()(to the output directory)
See the Lua Scripting guide for the full API reference.
Missed Run Detection
The scheduler tracks the last successful run time for each task in
.rela/scheduler-state.json. On startup, it checks whether any tasks missed their
scheduled window while the scheduler was not running.
Example: You have a daily task. The scheduler was stopped on Monday evening and restarted on Wednesday morning. On startup, the scheduler detects that Tuesday's run was missed and executes the task immediately before entering the normal schedule loop.
This applies to all schedule types:
- Day tasks: missed if the day changed since the last run
- Week tasks: missed if the ISO week changed since the last run
- Interval tasks: missed if more than the interval has elapsed
First Run
When a task has no recorded history (new task or fresh project), it executes immediately on startup.
State File
The state file .rela/scheduler-state.json is gitignored. Each developer or deployment
maintains its own scheduler state. If you delete this file, all tasks will execute on
the next startup.
Deployment
Running as a Service
The scheduler is designed to run as a long-lived process. Common deployment options:
systemd (Linux):
[Unit]
Description=Rela Scheduler
After=network.target
[Service]
Type=simple
WorkingDirectory=/path/to/project
ExecStart=/usr/local/bin/rela scheduler
Restart=on-failure
[Install]
WantedBy=multi-user.target
launchd (macOS):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.rela.scheduler</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/rela</string>
<string>scheduler</string>
</array>
<key>WorkingDirectory</key>
<string>/path/to/project</string>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
Graceful Shutdown
The scheduler responds to SIGINT (Ctrl+C) and SIGTERM. On receiving a signal, it:
- Stops checking for new due tasks
- Waits for any currently-running task to finish
- Exits cleanly
Logging
All task activity is logged to stderr with structured fields:
level=INFO msg="scheduled task" name=daily-check every=day script=daily-check.lua
level=INFO msg="first run, executing immediately" name=daily-check
level=INFO msg="task started" name=daily-check script=daily-check.lua
level=INFO msg="task completed" name=daily-check duration=45.2ms
level=INFO msg="scheduler started" tasks=1
Failed tasks are logged with the error message, at WARN for the first few consecutive failures and escalating to ERROR once retries are clearly not helping. The scheduler continues running — a failed task does not stop other tasks from executing.
Failure Handling and Retries
When a task fails, it is retried on a fixed backoff ladder:
5m → 10m → 20m → 40m → 80m → every 2h
Each consecutive failure moves one rung down; once the ladder reaches 2 hours it stays there, retrying every 2 hours until the task succeeds.
While a task is failing, the ladder replaces its schedule. The task fires only on retry steps, never on its normal cadence. This means the ladder is the same for every schedule, but its effect differs:
- A daily task that fails at 09:00 retries at 09:05, 09:15, 09:35, 10:15, 11:35, then every 2 hours — recovering from an intermittent failure without waiting a full day.
- A
5mtask that fails slows down to the same ladder instead of hammering every 5 minutes while it is broken.
A successful run is the only thing that resets the ladder. Elapsed scheduled slots do not: for a short-interval task a slot passes faster than the ladder climbs, so resetting on slots would prevent it from ever backing off.
Note that a retry is the run for that period, not an extra one. A daily task that fails at 09:00 and succeeds on the 11:35 retry has run for that day, and will not run again until the next day — so a recovered run can land some hours after its nominal slot.
Retry state is persisted in .rela/scheduler-state.json alongside the last-run
timestamps, so a task mid-backoff keeps its position across a scheduler restart.
level=WARN msg="task failed" name=daily-check duration=4.4ms failures=1 \
retry_in=5m0s retry_at=2026-08-13T23:12:04+02:00 error="..."
level=INFO msg="retrying failed task" name=daily-check failures=1 scheduled_for=...
level=ERROR msg="task failed" name=daily-check duration=4.1ms failures=4 \
retry_in=40m0s retry_at=2026-08-14T00:15:00+02:00 error="..."
If the scheduler finds a retry time further out than the 2-hour maximum — a symptom of a clock jump or a hand-edited state file — it logs a WARN and retries immediately rather than leaving the task stuck indefinitely.
Examples
Daily Orphan Report
# schedules.yaml
tasks:
- name: orphan-check
script: checks/orphans.lua
every: day
-- scripts/checks/orphans.lua
local entities = rela.list_entities()
local lines = {}
for _, e in ipairs(entities) do
-- Note the table: get_relations takes an options table, not a bare id.
-- rela.get_relations(e.id) ignores the argument and returns EVERY
-- relation, so `#rels == 0` would never fire and the check would
-- silently pass forever.
local rels = rela.get_relations({ from = e.id })
if #rels == 0 then
table.insert(lines, "- **" .. e.id .. "**: " .. (e.properties.title or "(no title)"))
end
end
local body = "## Orphan Report\n\n"
if #lines > 0 then
body = body .. "Found " .. #lines .. " unlinked entities:\n\n" .. table.concat(lines, "\n")
else
body = body .. "No orphaned entities found."
end
rela.update_entity("REPORT-orphans", {
title = "Orphan report: " .. #lines .. " unlinked",
status = #lines > 0 and "open" or "closed",
}, body)
Periodic Status Summary
tasks:
- name: status-summary
script: reports/status.lua
every: 4h
-- scripts/reports/status.lua
local types = {"requirement", "decision", "ticket"}
local lines = {}
for _, t in ipairs(types) do
local all = rela.list_entities(t)
table.insert(lines, "- **" .. t .. "**: " .. #all .. " entities")
end
rela.update_entity("REPORT-status", {
title = "Status summary",
date = os.date("%Y-%m-%d"),
}, "## Status Summary\n\n" .. table.concat(lines, "\n"))
Weekly Traceability Check
tasks:
- name: trace-check
script: checks/traceability.lua
every: week
-- scripts/checks/traceability.lua
local reqs = rela.list_entities("requirement")
local gaps = {}
for _, req in ipairs(reqs) do
local traces = rela.trace_from(req.id, "implements")
if #traces == 0 then
table.insert(gaps, "- **" .. req.id .. "**: " .. (req.properties.title or ""))
end
end
local body = "## Traceability Gaps\n\n"
if #gaps > 0 then
body = body .. #gaps .. " requirements without implementations:\n\n" .. table.concat(gaps, "\n")
else
body = body .. "All requirements have implementations."
end
rela.update_entity("REPORT-traceability", {
title = "Traceability: " .. #gaps .. " gaps",
status = #gaps > 0 and "open" or "closed",
}, body)
Audit log
Every write a scheduled task performs (via rela.create_entity,
rela.update_entity, etc.) is recorded in
.rela/audit/YYYY-MM-DD.jsonl with principal.tool: "scheduler" and
triggered_by: "schedule:<task-name>". This makes it easy to filter
the audit log for scheduler-driven changes:
cat .rela/audit/*.jsonl | jq 'select(.triggered_by == "schedule:traceability-report")'
principal.user is the task's identity: system:scheduler by default, or
its run_as value. Earlier versions recorded the OS account that started
the scheduler, so records written before the upgrade carry that instead —
worth knowing when reading back across the change.
See audit-log.md for the full record schema.