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.

trigger cron / event claim issue lock + worktree run workflow agents + steps open PR / comment reconcile on the next tick — the claim persists across runs
The autoflow loop: wake, claim, run, deliver — then reconcile again on the next tick.

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.

TriggerWhen it firesMechanism
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:

One logical event fires once. The deterministic run-id lets the polled tier and the webhook tier coexist on the same workflow without double-firing the same delivery — see Idempotency & ownership below.

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
FieldMeaning
enabledMarks this workflow as autonomously runnable.
entityThe entity type the autoflow owns. v1 supports issue.
priorityMatch precedence when multiple autoflows select the same issue; higher wins. Default 0.
selectorCandidate filter over issues. Portable v1 fields: states, labels_all, limit.
wake_onEvent ids that mark a candidate item dirty for reconciliation (the same vocabulary as triggers).
reconcile_everyMaximum time between reconciliations for an owned entity. Compact duration grammar: s/m/h/d.
claimClaiming policy. claim.ttl governs the ownership lease, not just retry timing.
workspacePersistent checkout policy. strategy: worktree gives one durable branch per issue. branch is the one template-rendered autoflow field.
outcomeNames 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.

Two modes, one file. Run through 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.

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.

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:

CommandPurpose
rupu autoflow listList 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 tickDiscover and reconcile every enabled autoflow once, then exit. The primary v1 runtime.
rupu autoflow serveRun the reconciler as a long-lived local worker (post-v1 follow-on); rupu autoflow stop stops it.
rupu autoflow statusSummarize active / waiting / retrying / complete claims.
rupu autoflow claimsInspect 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.