RFDs/0009

Durable jobs and agent capabilities

State
published
Authors
Darwin D Wu
Discussion
renga-dcim/renga#146
Labels
rengaagentapicontrol-planesecuritysoftware
Implemented
100%Checklist

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. Every observe capability is idempotent; inventory.collect@1 produces 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.

The job context, delivery endpoints, and sweeper know nothing about any one capability. RFDs 11 to 14 add capabilities as new modules without changing them. The agent has a matching Rust trait, and only capabilities compiled into the agent can be advertised.

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/lease sends the capabilities the agent will run now. The server leases up to the requested number of queued jobs for that installation using FOR 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/events appends started, 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: true when 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 started goes back to queued if attempts remain and not_after has not passed, whatever its class, because nothing ran.

  • A started idempotent or convergent job goes back to queued under the same conditions.

  • A started imperative job becomes uncertain. 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 uncertain rather 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.

It is not acceptable for operate or interactive jobs. Those require per-installation credentials that a leaked shared key cannot impersonate. Designing them (enrollment-time key pairs, mutual TLS, or signed lease requests) is a prerequisite of RFDs 13 and 14 and is out of scope here.

User interaction

  • A resource whose agent advertises inventory.collect@1 offers "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 not_after, then expires with an event.

Agent crashes mid-job

The lease lapses with lease_lost. An idempotent or convergent job is re-queued or expires; an imperative job that had started becomes uncertain.

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

  1. Add the schema, the job context, event immutability, and the lease sweeper.

  2. Add the agent lease and event endpoints and the check-in hint.

  3. Teach the agent [jobs] policy, the job worker, and inventory.collect@1.

  4. Add "Collect now", the Jobs view, and activity entries.

  5. Add the optional Channels notification.

Decisions from review

  • A new organization allows every observe capability; operate and interactive capabilities start disallowed.

  • A fresh agent enables its observe capabilities; everything else is off until configured locally.

  • Job history is kept for 90 days by default.

  • Each capability declares a retry class (idempotent, convergent, or imperative). The sweeper re-queues a started job only when its class allows it, an imperative job whose lease is lost ends uncertain, 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.Capability behaviour, with a matching Rust trait in the agent. Job storage and delivery are not pluggable: the tables carry tenant keys, append-only events, and SKIP LOCKED leasing 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 jobs and job_events tables 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.