RFDs/0009
Durable jobs and agent capabilities
Goal
Deliver the control primitive RFD 1 names: persisted jobs that agents lease over HTTPS polling, with explicit capability advertisement and agent-local policy. The first job type is deliberately harmless, an on-demand inventory collection, so the whole path (request, authorization, lease, execution, events, audit) can ship and harden before any operation that changes a host.
An optional Phoenix Channels connection may later notify agents that work is waiting. It never carries jobs itself, so polling stays the only correctness path.
Non-goals
-
Operational queries, live terminals, and VM lifecycle operations. They get their own RFDs (12, 13, and 14) and build on this one.
-
Arbitrary command or shell execution, under any capability name.
-
A general workflow engine. A job targets one agent and runs one capability. Multi-step workflows sit above jobs (see Orchestration), and their adapters get their own RFD.
-
Replacing scheduled inventory. Agents keep collecting on their own interval whether or not jobs work.
Capabilities
A capability is a named, versioned operation an agent can perform, written
name@version, for example inventory.collect@1. The existing capabilities
list on check-ins and agents keeps its meaning. Entries without a version
(today’s host.inventory) describe what an agent reports, not jobs it accepts.
Each capability has a risk class fixed by its definition, not by configuration:
-
observe: reads the host and reports, changing nothing else (on-demand collection, typed read-only queries). -
operate: changes host or guest state (VM power, service restarts). -
interactive: opens a session a person drives (terminals).
An agent advertises a job capability only when it is compiled in and enabled by local policy. Local policy lives in the agent’s configuration file, not in Renga:
[jobs]
enabled = ["inventory.collect"]
A fresh installation enables its observe capabilities, so on-demand
collection works without a configuration change; an operator can disable any of
them in [jobs]. operate and interactive capabilities are never enabled by
default, and Renga cannot enable them remotely. The agent re-checks its policy when it leases a job, so a job created
against an advertisement that has since been withdrawn is refused rather than
run.
Server-side authorization is independent: an organization setting lists which
capabilities may be targeted at all, and a user’s role decides which they may
request. A new organization allows every observe capability and no operate
or interactive one; owners change the list. Owners and admins may request observe jobs. Requesting operate and
interactive jobs needs grants that RFDs 13 and
14 define. Neither side’s
check substitutes for the other.
Retry classes
Running a job is at least once: a lease can lapse after the agent has acted but before its final event post arrives. Nothing can make an effect outside Renga’s database exactly once, so each capability’s definition also fixes how safe it is to run again:
-
idempotent: running it again has no further effect beyond a new report. Everyobservecapability is idempotent;inventory.collect@1produces a new observation, and observations are already deduplicated by ID. -
convergent: it drives the target toward a stated end state, so a retry first reads the current state and acts only if the target is not there yet ("power on", "set desired state"). -
imperative: each run is a new effect ("reboot", "reset", terminal input). It is never run again automatically.
Retry class is part of a capability’s version. Changing it means a new version, so a job’s retry rule cannot change after it was requested.
Capability modules
Each capability is one module implementing a Renga.Jobs.Capability
behaviour, and a registry maps name@version to its module. The module
declares:
-
its name and version;
-
its risk class and retry class;
-
how to validate its parameters and results;
-
its timeout and output limit;
-
whether its output is sensitive, and an authorization hook that can narrow, but never widen, the organization and role checks above;
-
how its result is shown.
Job model
jobs is an organization-scoped table. Each job targets one agent and carries:
-
the capability name and version, and parameters validated against that capability’s schema before the job is stored;
-
who requested it (a user, or the system for scheduled work);
-
an optional idempotency key, unique per organization, so a retried request creates one job;
-
not_after, the deadline for an agent to lease it; -
the current state, attempt count, lease token, and lease expiry.
States are queued, leased, running, succeeded, failed, cancelled,
expired, and uncertain. uncertain means the job may have taken effect but
Renga cannot tell; only an imperative job can end there, and only a person or a
later reconciliation resolves it. A request that fans out to many agents creates one job per agent
sharing a batch_id. Batches have no size cap. Instead, job creation is
rate-limited per organization (initially 10,000 jobs a minute), and agents
lease their own work, so a large batch spreads across the fleet’s check-in
intervals rather than arriving at once.
job_events is append-only and is the job’s history: created, leased,
started, progress, output, succeeded, failed, cancel_requested,
cancelled, lease_lost, expired, uncertain, and resolved, each with its actor (user, agent, or
system), time, and a bounded payload. A trigger rejects updates and deletes on
events. The job row is a projection kept in step with its events by one context
module that writes both in the same transaction.
Output is bounded per job (initially 64 KiB of events in total), so a job can never become an unbounded log sink. Jobs and their events are kept for 90 days by default; retention is an organization setting.
Delivery over HTTPS
Polling is the correctness path. All three endpoints use the existing intake authentication (RFD 3) and record contact like any other request.
-
POST /api/v1/agent/jobs/leasesends the capabilities the agent will run now. The server leases up to the requested number of queued jobs for that installation usingFOR UPDATE SKIP LOCKED, returning each job with a fresh lease token and a lease that expires in 60 seconds. -
POST /api/v1/agent/jobs/:id/eventsappendsstarted,progress,output, and the final result. It requires the current lease token, extends the lease, and is idempotent per event sequence number, so a retried post is not recorded twice. Its response says whether cancellation was requested. -
A check-in response carries
jobs_waiting: truewhen queued jobs exist for the installation, so an agent leases promptly without a separate polling loop. Otherwise the agent tries to lease once per check-in interval.
A server worker, following the existing periodic-worker pattern, sweeps expired
leases and records a lease_lost event for each. What happens next depends on
how far the job got and its capability’s retry class:
-
A job that never reached
startedgoes back toqueuedif attempts remain andnot_afterhas not passed, whatever its class, because nothing ran. -
A started
idempotentorconvergentjob goes back toqueuedunder the same conditions. -
A started
imperativejob becomesuncertain. Running it again needs a new job, requested and authorized afresh. -
Anything else becomes
expired.
Expired and abandoned work therefore never stays running forever.
Agent execution
Jobs run on their own worker thread with a concurrency of one at first, so they can never delay lease check-ins or scheduled inventory. Each capability has a fixed timeout, and the agent checks for cancellation between steps and on every event post. A job that outlives its timeout is reported as failed, not left running.
Before running a job, the agent writes its job ID to a bounded record in its
state directory, and it adds the outcome once the job ends. When the same job
is delivered again, after a lost final post or a re-lease, the agent re-sends
the recorded outcome instead of running the job again. A job that is recorded
as started but has no outcome (the agent crashed mid-run) is reported as
uncertain if it is imperative, and otherwise run again under its retry class.
The record outlives the longest lease and retry window. It is a guard against
duplicates, not the source of truth, which stays in Renga.
inventory.collect@1 runs the normal collection, delivers the observation
through the on-disk queue, and returns the observation ID as its result. It
proves the path without adding any new power over the host.
Phoenix Channels notification
A later phase adds a per-installation channel authenticated like the HTTPS API.
The server pushes jobs_waiting when it queues a job for a connected agent, and
the agent then calls the same lease endpoint. The channel never delivers job
content, results, or authority. If the socket drops or is never established,
the agent behaves exactly as it would without it.
Streaming interactive output (RFD 13) may later use the channel. That is that RFD’s decision, not a reason to route ordinary jobs through it.
Orchestration
Later RFDs need work that spans several jobs: RFD 14's power operations wait for approval, act, wait for the VM to settle, verify, and hand an uncertain outcome to a person; fan-out batches are tracked until they complete; reconciliation retries and remediation runbooks follow. That coordination is a workflow, and workflows sit above jobs.
Jobs and leases are the fixed execution layer. A workflow engine coordinates jobs but never leases them: agents sit behind NAT and pull work over HTTPS with their intake key (RFD 3), so no engine can call into them, and giving agents credentials for an engine’s own workers would replace Renga’s trust boundary and move the job history out of its tenant-scoped, append-only records.
The adapter boundary is a Renga.Workflows.Engine behaviour that starts a run,
delivers job events to it, cancels it, and reports its status. It does not
expose an engine’s replay or determinism rules, so capabilities and the job
context stay engine-agnostic.
-
The default adapter is built in: a Postgres state machine driven by the existing periodic workers, with no extra infrastructure. Renga is self-hosted and must not require operators to run a separate cluster.
-
Adapters for external engines such as Temporal or Inngest are opt-in.
-
A workflow step creates its job with an idempotency key built from the run ID and step ID, so an engine retrying a step creates one job. It waits for the job’s final event rather than polling. The engine may retry a step, but the job’s retry class still applies, so an imperative job ends
uncertainrather than running twice. -
Server-side capabilities with no agent, such as calls to a provider API, may later run as workflow steps behind the same capability behaviour.
The engine behaviour and its first adapters are designed in their own RFD, once RFD 14's workflow is written against this boundary.
Trust boundary
An intake key authenticates an organization, and the installation ID is a
correlation identifier, not proof of identity (RFD 3).
Anyone holding a key can
therefore lease jobs addressed to any installation in that organization. That is
acceptable for observe jobs, which reveal no more than the same key could
already submit.
User interaction
-
A resource whose agent advertises
inventory.collect@1offers "Collect now" to owners and admins. The result names the observation it produced. -
A Jobs tab in the Activity area, beside Changes, lists jobs by state, capability, target, and requester, with each job’s event history.
-
Job requests, outcomes, and cancellations appear in the resource’s Activity tab and the organization activity feed.
-
The Collectors page shows each agent’s advertised job capabilities next to its inventory capabilities.
Failure modes
| Situation | Outcome |
|---|---|
Agent offline when a job is created |
The job waits until |
Agent crashes mid-job |
The lease lapses with |
Final event post lost after the job ran |
On redelivery the agent re-sends the recorded outcome instead of running the job again. |
Lease token reused after re-lease |
Event posts are rejected; only the current holder can report. |
Capability withdrawn after the job was created |
The agent refuses at lease time; the job fails with a policy reason. |
Server restart |
Jobs and events are rows; work resumes on the next lease or sweep. |
Channel connection lost |
No effect on correctness; polling continues. |
Rollout
-
Add the schema, the job context, event immutability, and the lease sweeper.
-
Add the agent lease and event endpoints and the check-in hint.
-
Teach the agent
[jobs]policy, the job worker, andinventory.collect@1. -
Add "Collect now", the Jobs view, and activity entries.
-
Add the optional Channels notification.
Decisions from review
-
A new organization allows every
observecapability;operateandinteractivecapabilities start disallowed. -
A fresh agent enables its
observecapabilities; everything else is off until configured locally. -
Job history is kept for 90 days by default.
-
Each capability declares a retry class (
idempotent,convergent, orimperative). The sweeper re-queues a started job only when its class allows it, an imperative job whose lease is lost endsuncertain, and the agent records job outcomes so a redelivered job is not run twice. Exactly-once execution is not claimed. -
Capabilities are modules behind a
Renga.Jobs.Capabilitybehaviour, with a matching Rust trait in the agent. Job storage and delivery are not pluggable: the tables carry tenant keys, append-only events, andSKIP LOCKEDleasing in one transaction, guarantees an adapter would have to drop or rebuild. -
Pluggable orchestration lives a layer up, behind
Renga.Workflows.Engine, with a built-in Postgres adapter by default and external engines such as Temporal or Inngest opt-in. External engines coordinate jobs; they never lease them. -
Fan-out batches have no size cap; per-organization rate limits and agent-side leasing bound the load instead.
-
Agent jobs live in Renga’s own
jobsandjob_eventstables rather than a job library such as Oban. Those libraries run work on application nodes; these jobs are leased by remote agents over HTTPS with lease tokens, signed requests later, and a tenant-scoped append-only history, which a library’s state machine and table would have to be worked around to provide. The lease sweeper follows the existing periodic-worker pattern and is idempotent, so it is safe on every node. Adopting a library later for server-side background work (sweeps, retention, reconciliation retries) stays open.
Implementation
Progress is tracked in the implementation checklist.