Autoflows
An autoflow is a workflow that runs itself: a trigger wakes it, it claims a unit of work — an issue — runs the workflow in a durable worktree, and opens a PR. No human kicks it off.
What is an autoflow
A workflow answers two questions: how does one run execute and when may it start. An autoflow answers the questions a workflow can't: which issue is already claimed, who owns the repo when you're not standing in it, how do we revisit an issue after a PR merge or a backoff timer, and how do we keep one durable branch per issue across many runs.
The key design choice: there is one workflow YAML language. An autoflow is not a new file type and not a second
step DSL — it is an ordinary workflow file plus an optional top-level autoflow: block. The same file runs
manually through rupu workflow run or autonomously through the rupu autoflow … command family.
workflow stays the unit of execution; autoflow is the persistent, lifecycle-oriented runner for it.
Triggers
Any workflow can declare a top-level trigger: block that decides when a one-shot run may fire.
The trigger.on: field takes one of three values.
| Trigger | When it fires | Mechanism |
|---|---|---|
manual |
You run rupu workflow run <name> |
The default. Every install. No scheduler needed. |
cron |
The schedule in trigger.cron: (5-field UTC) matches |
System cron / launchd invokes rupu cron tick. There is no rupu daemon — the OS scheduler drives it. |
event |
A matching SCM event appears | Polled tier via rupu cron tick, and/or the live tier via rupu webhook serve (HMAC-validated). |
Cron
You install one crontab / launchd entry that runs rupu cron tick every minute. The tick walks every
cron-triggered workflow and fires those whose schedule matched between the persisted last_fired timestamp and now —
idempotent at one-minute granularity. rupu cron list is read-only and prints every cron workflow plus its next
firing time; --dry-run verifies a crontab line without firing anything.
# one line in your crontab drives every cron + event trigger
* * * * * /usr/local/bin/rupu cron tick
Events — two tiers, no double-firing
rupu reacts to SCM events two ways, and you can run both at once:
-
Polled (CLI-native). The same
rupu cron tickcalls each configured connector for new events between ticks. Configure sources in[triggers].poll_sources(empty by default — rupu polls nothing until you ask). Shipped connectors: GitHub repo feeds, GitLab repo feeds, Linear team feeds (linear:<team-id>), and Jira project feeds (jira:<site>/<project>). Latency is one tick interval. -
Webhook (live). Run
rupu webhook serveas a long-lived process under your own supervisor for sub-second latency and broader event coverage. The receiver validates each delivery's signature (X-Hub-Signature-256for GitHub,X-Gitlab-Tokenfor GitLab,Linear-Signaturefor Linear,X-Hub-Signaturefor Jira Cloud), maps the raw delivery onto the rupu event id, and fires matching workflows.
Webhook secrets are read from environment variables only — never config files, never the keychain. Bind to 127.0.0.1
and front with a TLS-terminating reverse proxy in production; rupu does not terminate TLS itself.
# split the tiers across different cadences if you like * * * * * rupu cron tick --skip-events # cron only, every minute */5 * * * * rupu cron tick --only-events # events every 5 minutes # or run the live receiver under your supervisor RUPU_GITHUB_WEBHOOK_SECRET=<secret> rupu webhook serve --addr 0.0.0.0:8080
The autoflow block
Adding an autoflow: block to a workflow file marks it as autonomously runnable. The runtime owns only the
autonomous concerns — candidate discovery, claims, repo-to-path resolution, worktree lifecycle, scheduling/retries, and structured
outcome handling. Repo-specific reasoning stays in the workflow steps:.
autoflow: enabled: true entity: issue # v1 owns issues priority: 100 # higher wins when many autoflows match one issue selector: # candidate filter over issues states: ["open"] labels_all: ["autoflow"] limit: 100 wake_on: # event ids that mark a candidate dirty - github.issue.opened - github.issue.labeled - github.pr.merged reconcile_every: "10m" # max time between reconciliations claim: key: issue ttl: "3h" # governs the ownership lease workspace: strategy: worktree # a durable per-issue worktree, not a temp clone branch: "rupu/issue-{{ issue.number }}" outcome: output: result # which declared workflow output the runtime consumes
| Field | Meaning |
|---|---|
enabled | Marks this workflow as autonomously runnable. |
entity | The entity type the autoflow owns. v1 supports issue. |
priority | Match precedence when multiple autoflows select the same issue; higher wins. Default 0. |
selector | Candidate filter over issues. Portable v1 fields: states, labels_all, limit. |
wake_on | Event ids that mark a candidate item dirty for reconciliation (the same vocabulary as triggers). |
reconcile_every | Maximum time between reconciliations for an owned entity. Compact duration grammar: s/m/h/d. |
claim | Claiming policy. claim.ttl governs the ownership lease, not just retry timing. |
workspace | Persistent checkout policy. strategy: worktree gives one durable branch per issue. branch is the one template-rendered autoflow field. |
outcome | Names which declared workflow output the runtime should consume. |
An optional sibling contracts: block declares the workflow's machine-readable output — the
from_step it comes from, its format, and the JSON Schema it must validate against (for example
autoflow_outcome_v1). The runtime parses that structured outcome to decide what happens next; it never parses prose.
rupu workflow run and the steps: execute normally while the
autoflow: metadata is ignored except for contract validation. Run through rupu autoflow … and the runtime
activates claim, worktree, outcome, and retry behavior.
Idempotency & ownership
Autoflows are unattended, so the whole tick algorithm is idempotent: running two ticks close together must never duplicate ownership or dispatch.
-
Deterministic run-ids. A polled or webhook delivery yields a deterministic run-id of the shape
evt-<workflow>-<vendor>-<delivery>. That is what lets the polled and webhook tiers process the same logical event without firing it twice. Event ingestion is at-most-once by design: if a process crashes after the cursor advances, the event is dropped rather than re-run, because re-firing a triage workflow is worse than missing one event during a crash. - One claim per issue. Before working an issue the runtime acquires an exclusive claim. Each claim records an owner id and a lease expiry, and the active cycle holds a lock file and renews the lease while it runs. A second process may steal only an expired claim whose active lock is absent — so two autoflows can never grab the same issue at once.
-
Deterministic precedence. It is legal for more than one autoflow to match an issue. v1 resolves it by evaluating
every autoflow whose
entityandselectormatch, choosing the highestpriority, and breaking ties by the workflownamethat sorts first lexicographically. Only the winner may hold the claim — no hidden first-match behavior. - Deferred dispatch. When an outcome asks to dispatch a child workflow, the request is persisted onto the claim and picked up on the next reconciliation cycle rather than run inline. That keeps dispatch idempotent and crash recovery simple.
Claim state lives under ~/.rupu/autoflows/ (one file per owned entity in claims/, durable per-entity
worktrees/, supervisor logs/), separate from the run's own RunStatus. A workflow paused at an
approval: gate maps the run to awaiting_approval and the claim lifecycle to await_human,
so the issue stays owned while it waits.
Event vocabulary
Both trigger tiers and wake_on: share one normalized event vocabulary. The polled connector lifts events from each
vendor's API and maps them onto canonical rupu ids, then derives broader semantic aliases on top.
- Canonical ids name a specific vendor delivery:
github.issue.opened,github.issue.labeled,github.pr.merged,github.push,gitlab.issue.opened,gitlab.mr.merged. - Semantic aliases express intent across deliveries and vendors:
issue.queue_entered,issue.queue_changed,pr.review_activity. They are matchable but never replace canonical ids. - Glob matching.
*matches any sequence of characters and does not special-case.boundaries:github.issue.*matches every GitHub issue event,*.pr.mergedmatches across vendors, and*wakes on anything.
Inside step prompts and the trigger filter: expression, the matched event is bound as {{ event.* }} —
for example {{ event.repo.full_name }}, {{ event.payload.issue.number }}, and
{{ event.canonical_id }}. filter: is the same minijinja you'd write in a step when:, evaluated at
match time against the event payload, and must render to true or false.
Operating autoflows
v1 is idempotent and tick-based — there is no mandatory daemon. An OS scheduler (launchd on macOS, a systemd --user
timer on Linux, Task Scheduler on Windows) invokes rupu autoflow tick periodically; each tick discovers enabled
autoflows, reconciles eligible issues, and exits. The rupu autoflow … family is the operator surface:
| Command | Purpose |
|---|---|
rupu autoflow list | List workflow files that declare autoflow.enabled = true. |
rupu autoflow show <name> | Print a workflow and its resolved autoflow metadata. |
rupu autoflow run <name> <target> | Run one autonomous cycle for one issue target (e.g. github:owner/repo/issues/42), bypassing discovery. |
rupu autoflow tick | Discover and reconcile every enabled autoflow once, then exit. The primary v1 runtime. |
rupu autoflow serve | Run the reconciler as a long-lived local worker (post-v1 follow-on); rupu autoflow stop stops it. |
rupu autoflow status | Summarize active / waiting / retrying / complete claims. |
rupu autoflow claims | Inspect persisted claims directly (subject, source, repo, branch, PR, status). |
rupu autoflow explain <ref> | Explain the current autonomous state for one issue. |
rupu autoflow release <ref> | Force-release a stuck claim. |
Event ingestion is shared with triggers: rupu cron tick drives both cron-scheduled fires and polled events
(rupu cron list / rupu cron events inspect what's wired), and rupu webhook serve feeds the live
tier. Autoflows consume those events as reconciliation hints via wake_on: rather than as direct one-shot
dispatch — they don't get a second polling config or a second cursor store.