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_as names a principal that acl.yaml never 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

ValueMeaning
dayOnce per day — runs after local midnight
mondayOnce per week on Mondays (after midnight local time)
fridayOnce per week on Fridays
weekAlias for monday
30mEvery 30 minutes
2hEvery 2 hours
1h30mEvery 90 minutes (any valid Go duration)
15Every 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:

  1. Stops checking for new due tasks
  2. Waits for any currently-running task to finish
  3. 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 5m task 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.