Skip to content

Chains ​

One skill per item is plenty for a red check. For an issue you'd rather see implemented, reviewed by the other agent and fixed before you look, a rule chains more steps after its skill, in the same clone and on the same branch.

No new chain features until real runs ask for them: only fixes and simplifications.

then ​

Steps run after the rule's skill, one after the other. The whole chain is one unit and publishes once, when it ends: one PR, one push or one report. To split work into PRs of their own, plan it into tasks. A step is a skill name, or a map:

yaml
queue:
  - skill: implement
    where: { type: issue, label: awaywork }
    then: review

  - skill: plan
    where: { section: Ready }
    model: { claude: opus }
    mcp: [sentry]
    then:
      - { skill: implement, model: { claude: sonnet }, instructions: notes/impl.md }
      - { skill: review, provider: codex, mcp: [] }

A step takes provider, model, skills, mcp and instructions, inherited from the rule unless set ([] clears a list), plus its own with and when. Above, implement gets sentry, and review gets no server and keeps opus should it route to Claude. Each step finds the previous one's output in .away/context/previous/: report.md, verdict, the chain's pr.md so far, its commits in diff.patch, and in a loop the until output in check.log. The first step finds the item's latest report there instead, marked as from an earlier run: that's how an approved implement finds its plan, on a retry too, since a failed implement with no report doesn't replace it.

plan shows a chain as one row, every step routed (every loop round up to max) and budgeted live:

Units 1
     item    rule  skill                 provider  deliver   title
  1  api#12  #1    implement (built-in)  claude    draft_pr  Rate-limit the export endpoint
     then loop review ⇄ fix until verdict approve, at most 3 rounds
       review (built-in) on codex
       fix (built-in) on claude
     then docs (user) on claude, when the branch changes src/**

The PR body is the pr.md the steps kept, plus an ## Awaywork chain section with each loop's rounds, its failing check if any, and each report step's latest report.

How a chain ends:

  • A step that leaves nothing for the next ends it (a committing step with no commits, a report step with no report), and the rest are passed over; the chain counts as finished.
  • So does a step that asks: .away/out/questions.md plus ask in .away/out/status (implement on a vague item). awaywork posts the questions on your item and publishes any commits, a PR as a draft; your reply reruns the unit, within minutes under run: soon (Questions).
  • So does continue: its commits open as a draft a later run goes on with (Continue).
  • A failed or stopped step, or the window's end, ends it and publishes what it has. A PR it opened or commits it pushed wait for your review; otherwise it runs again next run. Ctrl-C publishes nothing.

Details:

  • A skill commits unless its metadata says it reports, as plan and review do. A chain that committed nothing publishes its reports, joined, as one.

  • The rule's deliver decides where commits go; steps have no deliver or branch. A committing skill that should only report is a skill of your own.

  • model: { claude: [sonnet, opus] } retries a failed step on the second, and the chain goes on.

  • A chain counts once against caps and its rule's units, once its first step has a provider; over a cap it's skipped whole.

  • On someone else's PR or a fork's, a chain that could commit is skipped with the reason; deliver: report makes it a review chain, and pending_review leaves its last report as your pending review.

  • A skill runs at most once outside loops, and once across loops: a loop may repeat the rule's skill, as in fix then loop: [review, fix].

  • Steps are named <unit>-<skill>, in a loop <unit>-<skill>-<round>, each with a numbered folder under the run's units/<unit>/, next to out/ with what the chain published:

    units/acme-api-12/
      1-implement/   context/  prompt.md  worker.log  out/  replay.json
      2-review/
      3-fix/
      out/           pr.md  diff.patch
  • awaywork replay rebuilds only a unit's first step: later ones start from commits that only existed in the clone.

Loops ​

A loop repeats its steps until a condition holds or max rounds ran. Review and fix until the other agent approves:

yaml
queue:
  - skill: implement
    where: { type: issue, label: awaywork }
    then:
      - loop:
          - { skill: review, provider: codex }
          - fix
        until: { verdict: [approve, comments] }
        max: 3

until is checked after every step and ends the loop as soon as it holds; with both keys, both must. verdict is the latest word a loop step wrote to .away/out/verdict (review writes approve, comments or changes): a word, a list, or { not: … }, which doesn't hold until some loop step wrote one. command holds when it exits 0. max is at most 5. Rounds work in the chain's clone and publish with it.

A command, in until or when, runs in the clone, outside the model, like setup on a worker's branch:

  • No repo secrets, at most 15 minutes (past that it fails), never past the window's end, and never on an untrusted author's PR: one not run doesn't hold for when and ends the loop either way.
  • setup doesn't rerun, so a command needing a step's new dependencies installs them (pnpm i --frozen-lockfile && pnpm test). It reruns only once a step committed.
  • In until it fails while the clone has uncommitted changes beyond what setup and earlier checks left, since they wouldn't publish; when sees them, as the step will.
  • Stopped by the window's end, it ends the chain, which publishes; a when stopped that way doesn't pass over its step. After Ctrl-C nothing publishes and the chain is tried again.
  • A loop that ends on a failing command puts its output's tail in the PR body.

A step that leaves nothing for the next ends only its loop: a fix that disputes every finding commits nothing, and another review would repeat itself. Steps after the loop still run. Loops can follow loops, and a step with a when can come right before one.

when ​

A step runs only when its when holds; otherwise it's passed over as if absent:

yaml
queue:
  - skill: implement
    where: { type: issue, label: awaywork }
    then:
      - loop:
          - review
          - { skill: fix, when: { verdict: changes } }
        until: { verdict: [approve, comments], command: pnpm test }
        max: 3
      - { skill: security-review, when: { changed: "src/auth/**" } }   # a skill of yours
  • verdict: the chain's latest verdict, by any earlier step, matched as in until, except { not: … } also holds when none was written.
  • command: as in until, before the step's agent. ! runs a step only when a check fails (when: { command: "! pnpm test" }), its output in .away/context/previous/when.log. One that times out ends the chain without passing over the step.
  • changed: a glob or list, holding when the chain's commits change a matching path since where it started (the default branch or the awaywork PR a rule took up, as first cloned, so moving a ref changes nothing). Git globs from the repo root, no leading /: *.md matches README.md, not docs/a.md; src/auth/** everything under it.
  • Every key must hold. plan shows each when; the report says which failed.
  • A passed-over step doesn't end a loop, unless its command didn't run (it wouldn't later either). A round with every step passed over ends it.

quota ​

A rule's quota checks quota, and skips the whole chain when it doesn't hold:

yaml
queue:
  - skill: implement
    where: { type: issue, label: awaywork }
    quota: { headroom: 20% }    # only on spare quota
    then:
      - { loop: [review, fix], until: { verdict: approve }, max: 5 }

runs_until_reset holds when at most that many window openings are left before the long window resets (1 is the last window, or a manual run with one left); headroom when at least that share is left under this run's ceiling. Both read the quota the run last saw and fail without a reading; plan notes it. They're rule keys only: a step's when takes neither, and a rule takes no when.

push ​

By default awaywork only pushes new branches. deliver: push adds commits onto your own open PR instead of stacking a draft on it, say to address review threads in place:

yaml
queue:
  - skill: fix
    where: { type: pr, mine: true, unresolved_threads: { gt: 0 } }
    deliver: push

The chain pushes once, at the end, every step's commits included. The agent writes .away/out/pr.md as for a PR (no pr.md, no push); the PR body stays yours, and pr.md with the chain's notes goes into the morning report.

After a push the PR waits for your review: no rule takes it while its head is awaywork's commit, except after a sync's clean merge. Any commit on top, a rebase or an amend releases it. A bot that commits on every push (an autofixer, a snapshot updater) releases it at once, so the rule pushes every run: keep that repo out, e.g. repo: { not: acme/web }.

Details:

  • Only onto an open PR you authored, awaywork's included (opened with your login). On someone else's PR, a fork's, or an item without your PR, a chain that could commit is skipped with the reason.
  • Fast-forward only, on top of where the clone started; a branch that moved since is refused. New commits including a merge aren't pushed, but for sync's.
  • Right before the push awaywork rereads the PR and refuses if it's no longer yours or open, has auto-merge on (it would merge unreviewed commits), or its branch is on a fork, the default branch, protected, or another person's open PR's branch too.
  • Commits get the Awaywork: trailer, which drops signatures.
  • Only a rule opts in; a skill's frontmatter can't default to push.
  • The pushed head lives in the state dir, which a GitHub-hosted runner doesn't keep: there the rule pushes every run its where holds.

sync ​

Your PR is green, but main moved on. sync is a built-in skill where awaywork merges the PR's base into its branch itself and pushes the merge, like a push rule. A model steps in only to resolve conflicts.

yaml
queue:
  - skill: sync
    where: { type: pr, mine: true, behind: { gt: 0 } }
    deliver: push

behind counts base commits the branch lacks (GitHub fields). Leave conflicts out of where: GitHub resets it to unknown after a push to the base, so conflicts: false would miss the PRs that just fell behind; sync reads it itself. Without conflicts: true no model runs, so plan lists no provider, and the run pushes one Merge <base> into <branch> commit with the trailer:

     item     skill  status  result
  1  api#188  sync   done    pushed onto https://github.com/acme/api/pull/188
     Merge awaywork-sync-base into awaywork-sync-feature: 2 commits, awaywork-sync-base at f25506b.

Details:

  • awaywork runs git merge --no-ff of the base as fetched, first thing, before any repo code, with your identity and no signing config. A clean merge runs no worker, spends no quota and gets no gates: no model wrote anything.
  • A clean merge can still be wrong (two branches adding the same block), so setup then verify run on the merged tree, the only repo code here; a failure goes into the report and the push still happens, for your checks: failing rule to pick up.
  • Only that merge is pushed. If setup or verify amend it, commit on top or move the branch, nothing is, and the report says the head isn't awaywork's merge.
  • An up-to-date branch pushes nothing (already up to date with <base>), a finished attempt: the PR is unchanged until it or its base moves, and awaywork remembers the base head it tried, since a base push doesn't touch the PR's updated.
  • No review hold after a clean merge, so the next sync or a fix on the CI it started can take the PR at once.
  • A rule of its own: deliver: push required; a then or quota on it, or sync as another rule's step, is a config error. Everything push checks holds, GitHub only.

Conflicts ​

When the PR reads conflicts: true at plan time, the unit gets a provider and quota like any other. awaywork still makes the merge before any repo code, leaves the conflict in the clone, markers and all, and lists it in .away/context/merge.md:

markdown
awaywork merged `awaywork-resolve-base` (at 95202a5, 2 commits) into `awaywork-resolve-feature`, and these files conflict:

- resolve-probe/a.txt

The commits on `awaywork-resolve-base` that touch them, newest first:

- da5074a resolve probe: theirs

The worker resolves the files, keeping both sides' intent, fixes fallout elsewhere (a function the base renamed), runs its checks and writes .away/out/pr.md. It doesn't commit: awaywork makes the merge commit from its working tree, on exactly your head and the base head it merged, whatever the worker did to .git. Then it's a normal push: verify, the gates, and the summary in the report, the decision card on top when one fired:

     item     skill  status  result
  1  api#191  sync   done    pushed onto https://github.com/acme/api/pull/191, gate resolved fired
     - resolved: resolve-probe/a.txt

Details:

  • Gates read the resolution, not the base: git's automatic merge, markers included, is kept as a local commit never pushed, and gates diff from it.
  • A changed gate fires on paths the model changed on top, and always on the conflicted ones, whatever was kept (keeping your side of a binary still is a call made); a gated path only the base touched doesn't fire. $AWAYWORK_FROM is that commit: diff it to HEAD with two dots, since ... would read every base commit. A judge's prompt lists the conflicted paths.
  • The summary opens with Resolved: and the paths, then Also changed: with whatever else is on top of git's merge (fallout fixes, setup's edits to tracked files), above the worker's lines.
  • The merge takes the files git's merge holds as the worker left them, deletions included, nothing new: a new file from setup, the worker or checks stays out (Left out, untracked: generated.txt, scratch.log). A resolution that needs one asks instead.
  • The push is held for your review, as any push.
  • Nothing is pushed, each counting as an attempt, when:
    • setup or the worker committed or moved HEAD;
    • a conflicted file still has a <<<<<<< or >>>>>>> line (longer ones too, per conflict-marker-size) neither side had, so fixtures with markers are fine;
    • the resolution keeps nothing of the base (as git merge --abort would), or reverts a file the base changed outside the conflicts, deletions included (the resolution undoes main's change to x.txt, not pushed: a fallout fix gives new content, it doesn't revert);
    • the worker failed, stopped or asked, the question going on the PR.
  • A conflicted lockfile often breaks setup, which runs on the conflicted tree first: merge.md then notes it, and the worker goes on.
  • conflicts reads unknown for a while after a base push, so a run may plan no provider and hit the conflict: it aborts, pushes nothing, says where (conflicts with awaywork-resolve-base in resolve-probe/a.txt, and no provider to resolve them), and counts no attempt, so a later run plans a provider. Same when providers had no quota.
  • A conflicted submodule pointer, or a file name that isn't valid UTF-8, aborts with no worker or quota and asks you to merge by hand (conflicts with main in vendor/lib (a submodule): merge it by hand), a finished attempt like already up to date.
  • The instructions are the skill's body, so you can eject sync to tweak how it resolves; keep metadata.merge: base.