Skip to content

Autonomy — self-running projects

Most projects run tasks you submit. An autonomous project also runs tasks it decides to create — on an interval, toward a standing goal. It's how you turn vornik from "do this when I ask" into "keep this running and tell me when something needs me."

Autonomy is off by default. A project with autonomy disabled never starts an evaluation loop, so turning it on is an explicit, per-project decision.

How it works

When a project has autonomy enabled, the daemon runs one evaluation loop for it. On each tick it looks at the project's goal and current state and decides whether to create a task. There are three decision modes:

autonomy.mode What fires each tick
llm (default) a lead model is given the goal and project state and decides whether to create a task (and what), or to do nothing
cron the goal text is fired verbatim as the task prompt every tick — a deterministic time-driven loop
backlog the first unchecked - [ ] item from a backlog file is fired, then ticked off

Enabling it

Autonomy is configured per project under an autonomy block:

autonomy:
  enabled: true
  mode: llm
  goal: "Scan the configured feeds and open a digest task when something material lands."
  pollInterval: "30m"        # must carry a unit ("30m", "60s") — a bare number falls back to 5m
  maxTasksPerHour: 4         # 0 = unlimited
  requireApproval: false     # true parks created tasks for operator approval

Useful keys:

Key Meaning
autonomy.enabled master switch for the project (default false)
autonomy.goal the standing objective (required for llm/cron)
autonomy.mode llm / cron / backlog
autonomy.pollInterval tick cadence (Go duration; default 5m)
autonomy.maxTasksPerHour per-hour cap on self-created tasks
autonomy.allowedTaskTypes restrict what task types autonomy may create
autonomy.requireApproval create tasks as awaiting-approval instead of queued
autonomy.duplicateWindow how long a completed task suppresses an identical one (default 24h; 0 for cron-style)
autonomy.workflow_id override the workflow a backlog- or cron-mode tick dispatches into (see Backlog autonomy and agent deposits)
autonomy.feeds declare the recurring slugs an llm-mode goal rotates through, and the cadence each should hold, so drift is measurable (see below)

Declaring feeds (autonomy.feeds)

An llm-mode goal that rotates through recurring topics usually names them in prose, and prose is not something the daemon can measure a schedule against. autonomy.feeds declares them as data:

autonomy:
  enabled: true
  mode: llm
  pollInterval: "4h"
  goal: "..."
  feeds:
    - slug: czech-news
      cadence: "12h"
    - slug: cultural-events
      cadence: "1440h"
  • slug must match the "<slug>: " prefix your goal already uses for that topic's task prompts — that prefix is how a task is correlated with its feed.
  • cadence is a Go duration and must be positive. Slugs must be unique within the project; both rules are checked when the config loads.
  • Declaring feeds does not schedule anything. The lead still decides what to run; this is the expectation that decision gets measured against, and it is what vornikctl autonomy health reports cadence drift from.
  • Omitting feeds is fully supported. A project that declares none reports not declared in the health table — never OK, because nothing was measured.

Sum your cadences before you commit to them: one tick creates at most one task, so a 4-hour pollInterval can deliver six tasks a day in total. A set of cadences that needs more than that is a backlog, not a schedule, and every feed in it will read as permanently overdue.

Deploy the daemon before the config. Project YAML is parsed strictly: a daemon that does not know a key rejects the whole file and drops the whole project — not just the new feature. Install the version that supports autonomy.feeds first, confirm it is running, and only then add the key. To roll back, remove the key first and downgrade afterwards.

Staying in control

Autonomous work runs inside the same guardrails as everything else, and a few that are specific to it. On each tick, before any model cost is incurred, the loop checks — in order — the per-hour task cap, the shared rate limits, and the project's spend caps (a hard-cap breach skips the tick entirely). It also won't schedule on top of in-flight work: if the project already has a queued or running task, or one awaiting approval, the tick is skipped. The recorded reason counts those tasks by status and, for an approval, says how to release it. And it deduplicates — an identical prompt that recently completed (within the duplicate window) won't be created again, so a steady loop doesn't pile up copies.

Additional bounds worth knowing:

  • Approval gate. With requireApproval: true, every autonomously created task lands in an awaiting-approval state and runs only once you approve it (see Approvals). Stale approvals are auto-cancelled after a configurable window (autonomy.approval_timeout_hours, default 96).
  • Tighter tool budget. When dynamic tool budgets are enabled, unattended autonomous tasks are held to the tighter tool_budget.autonomy_max_factor ceiling rather than the operator ceiling.
  • Circuit breaker. A daemon-level breaker (autonomy.circuit_breaker.*) automatically disables a project's autonomy if it sees sustained failures, and alerts you.

To stop autonomy, set autonomy.enabled: false (or toggle it from the project page in the UI) — the loop is cancelled.

Backlog autonomy and agent deposits

backlog mode reads a plain checklist file — BACKLOG.md by default (autonomy.backlogFilePath to point at another workspace-relative path, never inside .git/) — and fires the first pending line as a task, ticking it off when the task is accepted. You can hand-author that file, but agents can feed it too: any role permitted to call the backlog_deposit tool can record an off-scope finding — a bug, an optimisation, an inefficiency, or a refactor candidate it noticed while working on something else — without derailing its current task. A reviewer, for example, can use it for problems it spots in a diff that aren't in scope for that PR's verdict.

Before each tick reads the file, the workspace is refreshed to the tip of origin/main — so every iteration works against the latest code, picking up external contributions (merged PRs) since the last run. Your local - [x] / - [!] consumption marks are preserved across that refresh, so an item is never re-run. The refresh is best-effort: if the fetch fails, the tick proceeds against the current workspace rather than stalling.

The marker grammar

One consumer owns every read/modify/write of the file, and it recognises four markers:

Marker Meaning Written by Picked up by the next tick?
- [ ] Pending, ready to run You (hand-authored), or you flipping a - [?] Yes — first-in, first-out
- [?] Proposed, awaiting your review backlog_deposit — always No — inert by construction
- [x] Done — the task completed (raised its PR) Marked at dispatch, then confirmed by the reconcile pass when the task completes No
- [!] Blocked — the task ended unsuccessfully The reconcile pass (below) No — flip to - [ ] by hand to retry once you've addressed the cause

An item is only truly done when its task completed (raised its PR). Each tick's reconcile pass checks the dispatched items: a task that ended unsuccessfully — failed, cancelled, or closed after giving up (e.g. an infinite-rework guard trip) — has its line flipped from - [x] to - [!] (blocked). This way the item is neither silently skipped as done nor auto-retried into a storm: you flip - [!] → - [ ] by hand to retry once you've addressed the cause (granted a permission, fixed a flaky test, split a too-big item), or delete the line if it's not worth retrying. (A task closed after a clean completion keeps its - [x] — only a failure indication flips it.) A task that can't publish its change — e.g. the forge rejected the push — is a separate case: it parks in AWAITING_INPUT with a mail-in patch attached and keeps its - [x], so it waits on you rather than looping.

Agent-authored deposits always land as - [?], never - [ ], and there's no config knob to change that. A deposit's title and detail are agent-authored text, and once a - [ ] line is consumed it becomes a verbatim task prompt with no human in between — so a deposit that could land straight into - [ ] would let a confused or compromised agent's own words steer a future autonomous task. The FIFO scan only ever matches - [ ], so a - [?] item is structurally unreachable by autonomy until you edit the box yourself. That's the per-item approval gate: agent-authored text never becomes an autonomous prompt without a human decision.

Granting the deposit tool

permissions:
  allowedTools:
    - "backlog_deposit"   # plus the role's usual tools (file_read, run_shell, git_*, ...)

The agent calls it with a kind (bug | optimisation | inefficiency | refactor), a short title, a detail, and an optional evidence string. The daemon validates the fields, secret-scans the rendered line (a match is blocked, never appended), rate-caps accepted deposits per task with backlogDeposits.maxPerTask (default 10), and dedups against existing lines — an exact title match or a close paraphrase (fuzzy match) of an open item is rejected as a duplicate. A close match against an already-closed (- [x] / - [!]) item needs regression: true plus non-empty evidence to re-open it, and is still refused for 7 days after that item was closed, so a flapping check can't spin the same "regression" back in immediately. None of these rejections fail the agent's task — it gets a structured reason back and moves on:

backlogDeposits:
  maxPerTask: 10   # default; per-task cap on accepted deposits

Shipping deposits as draft PRs

backlog-mode ticks normally dispatch into the project's default workflow (commonly dev-pipeline, which commits straight to the local clone — no PR, no review). Point backlog mode at the purpose-built delivery pipeline instead with autonomy.workflow_id: "backlog-item": it runs the same analyze → implement (TDD) → test → review loop, then a deterministic publish step pushes the branch and opens a draft pull request — no agent ever runs git push or a forge CLI. See Backlog-origin pull requests for what that PR looks like.

That publish step needs an outbound repo to target — there's no inbound webhook naming one for backlog-originated work — so backlog-item also requires github.repo (and a working GitHub App installation; see Forge) on the project. Setting autonomy.workflow_id to a workflow that doesn't exist is loud, not silent: it's flagged at config load/reload, and the tick itself skips (recorded, not a silent fallback to the default workflow) rather than guessing. The skip is recorded with outcome WORKFLOW_INVALID and a reason that names the missing workflow and the fix: correct autonomy.workflow_id, or load the workflow.

Putting it together, a project with backlog autonomy pointed at draft PRs looks like:

github:
  app_id: 123456
  installation_id: 78901234
  private_key_path: /etc/vornik/secrets/forge-app.pem
  # Outbound repo for work with no inbound event to name one —
  # backlog-item's draft PRs target this.
  repo: "your-org/your-repo"

autonomy:
  enabled: false            # flip on when you're ready for deposits to become PRs
  mode: "backlog"
  workflow_id: "backlog-item"
  maxTasksPerHour: 1
  pollInterval: "45m"
  allowedTaskTypes:
    - "backlog"

permissions:
  allowedTools:
    - "backlog_deposit"

backlogDeposits:
  maxPerTask: 10

With autonomy.enabled: false, deposits still flow — any role holding the tool can propose - [?] items — but nothing consumes them yet. Flip enabled: true only once you're ready to have hand-approved (- [ ]) items turn into draft pull requests.

If a backlog-item run exhausts its retries or hard-fails a step, it terminates with no PR opened. The next tick's reconcile pass finds the terminal-failed item and flips its box from - [x] to - [!] in the backlog file, so nothing is silently lost — investigate, then flip it back to - [ ] to retry, or delete the line if it's not worth retrying.

Refreshing the workspace before scheduled work

New in 2026.10.7. A task from cron or llm autonomy, or from a task-kind reminder, normally starts from whatever the project workspace last held. If the workspace is a clone of a repository that changes elsewhere, opt in to a reset before each such task:

workspace:
  refresh_before_work: true
  refresh_branch: main   # optional; default: the workspace's current branch

Before the task's worktree is created, vornik fetches origin and resets the workspace to origin/<branch>. This is the same reset a forge task gets:

  • commits that exist only in the workspace (for example a git-over-HTTPS push that never reached origin) are moved off the branch and kept in a refs/vornik/discarded/<time>-<sha> ref you can recover them from;
  • uncommitted changes to tracked files in the workspace itself are discarded;
  • untracked files are left alone.

So opt in only for a project whose workspace should mirror origin, such as one that reads a repository, not one whose agents commit work that is never pushed.

What it does not cover: tasks you submit by hand, from chat or from a companion, and child tasks, start from the workspace as it is. A backlog-mode project already refreshes at every tick and keeps its checklist marks across that refresh, so the key is refused with autonomy.mode: backlog. A detached HEAD with no refresh_branch is not refreshed. The fetch is bounded at 90 seconds; a private repository needs a credential the daemon's git can use.

The refresh never stops a task: when it cannot run, the task starts from the workspace as it is. Every attempt is counted in vornik_executor_workspace_refresh_total{project, source, outcome} and logged. outcome is refreshed or the reason it was not: no_origin, fetch_failed, reset_failed, not_git, skipped_no_branch, skipped_has_children, skipped_resume or skipped_backlog_mode.

Watching it

Every evaluation — whether it created a task or not — is recorded with an outcome (created, no-action, rate-limited, budget-blocked, duplicate, and so on). Inspect the audit trail and a rollup from the CLI:

vornikctl autonomy evaluations --project my-project --limit 50
vornikctl autonomy summary     --project my-project --hours 24
vornikctl autonomy health      --project my-project --hours 24

autonomy health is the one to reach for when every tick reports success and you still suspect the loop has quietly degraded. It puts four things in one table: whether one outcome has taken over the window, how each declared feed's observed cadence compares with the cadence you declared, what each tick actually delivered (following the delegation edge to the child that did the work, not the router that picked it), and how much routing churn that cost. Add --json for the raw payload.

It is deliberately explicit about what it did not measure. A project with no declared feeds prints not declared, a window with no judge verdicts prints no verdicts, and a number that could not be collected prints not measured.

The cadence column makes one more distinction worth knowing, because only one of the two is a real finding. Health reads a bounded page of the project's most recent tasks, so "this feed has no task in it" can mean two different things:

  • The page did not fill. That was the project's whole history, and the feed really has never run — so the row prints never ran.
  • The page filled. Older history exists that was not looked at, so the feed may have run before the window or may be badly overdue, and this reading cannot tell you which. The row prints a lower bound instead — >233h0m0s, meaning "at least this long" — followed by a note naming the feeds affected and how far back the window reached. Where that bound already exceeds the declared cadence, the feed is overdue no matter what the unexamined history says, and the BREACH column still says slow.

None of these are 0, because a control that cannot tell "examined and clean" from "never examined" would report the first and mean the second.

The project's home page shows a countdown to the next autonomy tick and the last evaluation's outcome, and the dashboard surfaces a next-evaluation tile across all autonomous projects plus a count of tasks awaiting approval.

The vornikctl autonomy commands are read-only audit views. Enable or disable autonomy through the project's configuration or the Web UI — there is no vornikctl autonomy enable command.