# The runner `knotless-run` is knotless's CI runner. It watches a set of repositories and runs their workflows when a ref moves or a pull changes. It reports each run as a `dev.knotless.check` record in its own atproto account. A workflow is a YAML or JSON file under `.knotless/workflows/`, read at the commit being checked. Its format is in [the workflow reference](../reference/workflows.md), and the runner's flags are in [the CLI reference](../reference/cli.md). Some background. A knotless repository is records in its owner's atproto account, kept on the account's server, its PDS. Each ref is one `dev.knotless.ref` record holding the ref's target and a generation, a counter that goes up with every push to that ref. The owner's PDS is the authority on where a ref points ([storage.md](storage.md)). Pulls, and the states that open and close them, are records in each author's own account. knotless-serve can index them as an appview, a service that lists records across accounts ([collaboration.md](collaboration.md), [serve.md](serve.md)). The [atproto site](https://atproto.com) has the protocol itself. ## What it does The runner logs in as one account, from `KNOTLESS_IDENTIFIER` and `KNOTLESS_PASSWORD` (an app password), or with `knotless login`. An app password reads no private repositories, so a runner that watches one logs in with OAuth: on a server, through the gateway beside it ([Bots](gateway.md#bots)), whose session lasts two years. Its arguments name the repositories to watch, each as `/`; a private one is read as that account, a member of it (see [Private repositories](#private-repositories)). The string as given is the repository's label, which names it in logs, in `KNOTLESS_RUN_REPO` and in the secrets file. Internally the runner knows a repository by `/`, where the key is a TID (a time-ordered record key) fixed when the repository was made, so a rename or a new repository under the old name does not fool it. Each run's check record holds its status, each step's result, a log and any images the run made. `knotless checks`, the web pages and `knotless merge` read them from the runner's PDS: from its repo in the space, for a private repository. ## What shapes the design Six problems decide most of how the runner works. 1. Events cannot be trusted. The runner learns of pushes from Jetstream, a public relay of every account's record changes. Its frames are unsigned, so anyone can forge one, and it has dropped events. 2. Pulls carry code from strangers. Anyone can open a pull, and its workflows and build scripts are theirs to write. 3. Deploys must not be cut off. A deploy is a workflow too, and stopping it halfway can leave a site down. 4. Every merge moves the base of every open pull. A pull is checked as merging would leave it, so each merge retests every open pull, and that burst must not starve the main branch's own runs. 5. Caches cross trust levels. A cache written by a stranger's pull must not reach a main-branch build, and one written by a run that had secrets must not reach a pull. 6. The runner stops and starts. It is redeployed, it crashes, and its host restarts. No run may be lost, and no check may stay `running` forever. ## Finding out that something changed Jetstream is only a doorbell. A frame tells the runner which repository to look at again, never what to run. The runner then reads the repository record, its refs and its rerun requests back from the owner's PDS, and runs the targets the PDS gives. A forged frame can make the runner read, but never run something the owner did not push. To ring a repository's door is the runner's term for waking that repository's reconcile task. Each watched repository has exactly one. A reconcile pass reads the repository and starts whatever is due. Rings that arrive while a pass is reading coalesce into one more pass. A frame rings a watched repository's door when it changes: - a `dev.knotless.ref` or `dev.knotless.rerun` record of the owner's for that repository; - the same kinds of record in a repository that an open pull into the watched one comes from (known only with `--pulls`, from reading pulls); - a `dev.knotless.pull` or `dev.knotless.state` record naming the repository, so a new, reopened or retargeted pull runs; - a `dev.knotless.queue` record, for the merge queue; - a `dev.knotless.repo` record of the owner's, which may no longer name the runner (see [Repositories that name it](#repositories-that-name-it)). Because a frame only rings, a missed one loses no work, and the runner rings doors in other ways too: - It connects without a cursor, and every connection rings every door. What passed while it was disconnected runs on reconnect, and a replayed event finds nothing new. A connection silent for 60 seconds gets a ping, and silent another 60 is given up and made again, as [knotless-serve's](serve.md) is: a half-open connection never ends, and the runner would hear nothing from it. - It rings every door every `--sweep` seconds (600). Jetstream once dropped a whole account's events for about 15 minutes on every instance. A pass runs only what has not run, so a sweep costs reads, not runs. - A pass that fails, say because the PDS is down, rings its own door again after `min(--sweep, 60 s)`. - With `--queue`, a change to the merge queue's records, re-read every `--notice` seconds (30), rings the door. - A private repository's space written to rings its door, as the appview passes on its authority's word of the write, at once; or, asked itself every `--poll` seconds (10), when anything was written there since the last ask. Nothing written in a space reaches Jetstream (see [Private repositories](#private-repositories)). ```mermaid flowchart TD frame[Jetstream frame naming a watched repo] --> ring connect[each Jetstream connection] --> ring sweep[every --sweep seconds] --> ring notice[queue records changed, every --notice seconds] --> ring poll[a private repo's space written to: the appview says, or every --poll seconds] --> ring retry[a failed pass, after at most 60 s] --> ring ring[ring the repo's door] --> pass[reconcile pass] pds[(owner's PDS: repo record, refs, reruns)] --> pass appview[(appview: open pulls, merge queue)] --> pass pass --> due{due, and trusted?} due -->|no| seen[recorded as seen] due -->|yes| file[file every check as running] file --> line[wait in line for a job] line --> steps[run the steps] steps --> ended[file the check as ended] ``` A pass is logged as it ends, with how long it took. One that hung for 13 minutes once said nothing of where, so a pass still going after 5 minutes logs the stage it is at, and again every 5 minutes, and a slow git command is logged as a slow HTTP call is ([What a pass logs](../reference/runner-internals.md#what-a-pass-logs)). ### Repositories that name it With `--discover`, the runner watches, besides those its arguments name, every repository whose record names its account in `runners`. That is how a hosted runner serves whoever names it, with no list kept by hand. It finds them three ways, and reads each before watching it: - At the start, and every `--sweep` seconds after, it asks the appview (`KNOTLESS_APPVIEW`) for `dev.knotless.listRepos?runner=`, and takes those it found before from the state file too, so a start does not wait on the appview. - A Jetstream frame writing a `dev.knotless.repo` record has it read that repository at once, so one named meanwhile is found within seconds. - A private repository's invites, read every `--sweep` seconds from the appview, from where the last read ended (`invited` in the state file): see [Private repositories](#private-repositories). Each found is read from its owner's PDS, or its space, as any ring is, before it is watched: the appview, the frame and the invite only say where to look. One whose record there does not name the runner, or that is archived, is passed over, and one watched that becomes either is left. Unarchiving writes the record again, which a Jetstream frame brings, so it is watched again within seconds. A repository found is labelled `/`, in logs, in `KNOTLESS_RUN_REPO` and in the secrets file. Its first pass starts from where its refs are, as any repository's does, but for its default branch's tip, which runs once: a repository made by its first push shows that push's checks, and one that names the runner later shows the runner works. What else was pushed before it was found waits for its ref to move. A pass that finds its record gone, or no longer naming the runner, stops watching it: what it was running finishes, and its door and the loops that ring it go. One renamed is found again under its new name, by its key, and watched by that instead. A repository named on the command line stays as it is, whatever its record says. A new repository names a runner of this kind without its owner doing anything. An appview started with `--default-runner ` names it in `dev.knotless.describeServer`, and `knotless create`, MCP's `create_repo` and a first push write it into a new repository's `runners`, unless the owner names others (`--runner`, or `--no-runner` for none); a private one's are made members of it and invited, and `knotless create` says so. knotless.dev names its runner host's account. It names one only: each runner a repository names runs all its checks, so two would run each twice. The record still says it all: the owner sees the runner with `knotless runners ` and lets it go with `knotless runners remove`. ### A reconcile pass A pass reads the repository record, then acts on new rerun requests, then runs what is due for refs that moved, then for pulls (with `--pulls`), then works the merge queue (with `--queue`), then, at most once an hour, prunes the repository's checks (see [Pruning](#pruning)). If the record's key has changed and no repository in the owner's account has the old key, the repository was deleted: the pass clears the checks, secrets and caches it left and stops watching it. A discovered repository whose record no longer names the runner is left. Otherwise a changed key fails the pass: the label no longer names the repository the runner was watching. A ref is due when its generation is past the one the runner last ran, or it is new to the runner, or its target matches a run dropped at a stop. A deleted ref never runs. Each due ref syncs the runner's mirror, a local bare git repository kept in step with the PDS, once per pass. After a ref's runs are filed, the runner records its generation in the state file. The first time the runner watches a repository, the refs, open pulls and rerun requests already there are its starting point: it runs none of them. A repository with many branches and pulls would otherwise run them all at once, and so would every repository again whenever the state file is lost, a deploy workflow's run on main included. `knotless rerun` runs any of them at once. Details: - A watched repository that cannot be found stops the runner starting. Later, an error in one repository is logged and the others go on. - The default branch is the repository record's `defaultBranch`, or `main`. ## What a push runs For a ref that moved, the runner checks out the new target and loads the workflows at that commit, so a change to a workflow is tested by the push that makes it. A workflow runs on a push when its `on.push.refs` matches the ref. Every workflow's check is filed before any run starts, as `queued` or already ended, so a reader never sees some of a commit's checks passed while another is missing. A run waits `queued` for its turns, and is filed again as `running`, its `startedAt` then, once it has its job and is about to run its first step. A pull's pre-flight that another runner the repository names has started or passed at the same head and base meanwhile is not run then: its check is deleted ([merge-queue.md](merge-queue.md#pre-flights)). A check is filed already ended for: - a workflow that does not parse, or two that share a name: `error`, whether or not it would have run; - a workflow whose `paths` match nothing that changed (below); - a run on the default branch that [reuses a pull's pass](#reusing-a-pulls-pass-at-the-push). Each commit keeps its own checks. A push leaves the previous commit's checks standing, and a rerun replaces only that commit's. ### Paths A `push` or `pull` trigger may list `paths` globs, and the workflow runs only if a changed file matches one ([syntax](../reference/workflows.md#paths)). For a push, the change set runs from where the runner's mirror had the ref before this pass synced it to the new target. When that is unknown, as for a new ref, nothing is skipped. A workflow left out is filed at once as `success`, every step `skipped`, with a note naming the paths. It takes no job and no cache, and the merge gate and deploys read it as passed. Runs asked for by name ignore `paths`. ## What a pull runs With `--pulls`, the runner checks each open pull into a watched repository at its head merged with the current tip of its base branch, as merging would leave it. A pull that breaks the base only beside another pull merged since then fails before it is merged. The runner lists open pulls from the appview (`KNOTLESS_APPVIEW`), counting only states written by the owner or a collaborator. Drafts are among them: a draft is checked as any pull is, so its author sees whether it passes before marking it ready, though it cannot merge or be queued until then. It reads each head from its owner's PDS and fetches it into the watched repository's mirror, as `knotless merge` does. A pull whose head branch, head repository or base branch is gone is treated as closed. If the head already contains the base, the runner checks the head itself. Otherwise it builds a merge commit with `git merge-tree` and `git commit-tree`, with a fixed author, committer and time. The same head and base always make the same commit, so the runner and `knotless merge` agree on what was checked without talking to each other. The workflows that run are those in that commit whose `on.pull.base` matches the base. If the head conflicts with the base, there is no merge commit to load workflows from. The runner loads them at the head, and files each that runs on a pull into the base as `failure` without running it, naming the files and asking for a merge or rebase. A pull's check names the head branch as its `ref`, the head as its `commit`, the base tip as `baseCommit`, and the pull. `KNOTLESS_RUN_SHA` is the head too, not the merge commit checked out, and `KNOTLESS_RUN_BASE` the base tip, as it is a batch's base in a batch's run. A repository named with `--pulls-only` runs only its pulls here. Its pushes and its merge queue run on another runner, one without `--pulls` that it also names. The pulls' runs then load a machine of their own, and the runs that land and deploy stay where the deploy's secrets are. That split is the operator's; an owner makes one of their own by naming the runners a workflow runs on, in its `runners`, and giving its secrets to those alone (`knotless secret set --runner`). A runner not named files no check of it. A push to the default branch can take the pass of a batch from the runner that lands the queue, but not the pass of a pull run elsewhere. Since no push runs here, a trusted pull's passing run writes the caches' bases instead, so the pulls here start warm (see [Where pushes run elsewhere](#where-pushes-run-elsewhere)). As another runner lands the queue, the pulls run here as on that one: a queued pull does not run on its own, and the rest run again only as their head moves, not their base. The state file holds the head and base each open pull last ran at, so on a runner that does not land the queue, when the base moves, every open trusted pull into it runs again. The sections on [scheduling](#who-gets-a-job-first) and the [merge queue](#the-merge-queue) bound what that costs. A pull that runs as it moves, trusted or an agent's, seen again after a gap, such as on reopening, does not run if the runner already has checks of it at that head and base. ## Whose code runs A pull runs as it moves only if it is trusted: its author and its head repository's owner both write to the repository, each being the owner or listed in the repository record's `collaborators`. A merge-queue batch is trusted when every pull in it is. An untrusted pull runs only when the owner asks, with `knotless rerun --pull`. It runs once, where it is at that moment, and until then `knotless merge` finds it not checked. A stranger cannot spend the runner's time, or reach its host, without the owner looking first. Between the two are the accounts the repository record names in `agents`, such as a coding agent pushing to its own fork. A pull whose author and head repository's owner are each an agent or may push runs as it moves, so an agent hears from the checks without waiting for the owner, but it runs untrusted: [caches](#across-trust-levels) apart for a workflow asking for secrets, nothing it writes kept, and so no pass a push can [reuse](#reusing-a-pulls-pass-at-the-push). What an agent writes can be steered by whoever wrote what it works from, so a collaborator who set it going does not vouch for it. An account named as an agent counts as one even if it is a collaborator too, and a batch holding an agent's pull is untrusted. A pull that brings [shared steps](reusable-workflows.md) from an account the repository does not vouch for is untrusted the same way: its lock names an account the base's lock does not, that is neither the owner nor a collaborator, or whose handle does not resolve. So is a batch holding it. Its log says so, naming the accounts. Trust decides when code runs, not what it is given. Every pull and batch run gets no secrets, trusted or not, and only the docker executor runs them: `--pulls` and `--queue` refuse `--executor shell`. The shell executor runs each step as the runner's own user, who can read the secrets file and the runner's session. Watch a repository under it only if you trust everyone who can push to it with the host. ## Who gets a job first All watched repositories share one pool of `--jobs` jobs (4). A seconds-long workflow never waits behind a minutes-long one in a queue of its own. A run takes everything it needs at once, when all of it is free: a job, and any [turn](#deploys-superseded-finish) on a ref it must hold. It never holds one thing while waiting for another, so two runs never wait on each other. Waiting runs are ranked by class, then by the order they were started. A run ranked earlier has first claim on what it waits for and on a free job. A later one goes ahead only with what nothing before it waits for, and a job left over. A running run is never stopped for one ranked before it, so main's runs wait only for runs already going. | Class | Runs in it | | --- | --- | | `Main` | pushes to the default branch, and the merge-queue batch that lands next | | `Other` | pull runs, and pushes to other refs | | `Later` | pulls not in the queue, where this runner lands the queue or runs the pulls alone, and a batch built ahead of one still checking | | `Asked` | runs asked for by name, after every other waiting run, however old | The classes other than `Main` together hold at most `--pull-jobs` jobs, half of `--jobs` by default. A merge that reruns every open pull starts only that many, and main's next run always has a job. Within a class, the owners of the repositories take turns. Each run of an owner's comes the turn after its last one still waiting, or, with none waiting, at the turn of the last run to start. A burst of one owner's runs, such as every open pull run again, goes one at a time between other owners' runs rather than all ahead of them, and one owner's runs keep the order they were started in. `--owner-jobs` caps the jobs one owner's runs hold at once, all of them by default. Past it, that owner's next run waits while other owners' runs take the jobs free. `--allowance ` sets how many minutes of steps each owner's runs may take in 30 days. Time is counted from the steps' own durations, not from when a check was filed, since that includes the wait for a job. Past its allowance, an owner's runs go one at a time, so they slow down but still finish. Each such run's log begins by saying so, and how long it waited for a job. The runner's own account is not held to it, nor is an owner named with `--unmetered`, and without the flag no one is. `--private-allowance ` does the same for private repositories' runs, counted apart from public ones. `--paused ` is the operator's stop: a JSON object of owner DIDs, or `*` for every owner but the runner's own account, each to why, such as `{"did:plc:…": "mining, by the looks of it"}`. The runner reads it again on every pass, so an edit takes effect at the next pass, with no restart. A paused owner's runs are not run: every workflow at the commit, whether or not it would have run there, is filed `error`, saying `paused by this runner's operator: `, never silently dropped. A file that cannot be read pauses no one; one that is not such an object fails the pass, and is logged. The runner files each owner's public minutes as a `dev.knotless.minutes` record in its own account, keyed by the owner's DID: the minutes in the last 30 days, the allowance, and when minutes counted now stop counting. It writes the record again as the owner's runs end, at most every five minutes, or at once when the owner reaches 80% of the allowance or passes it. Private repositories' minutes are never in it, since the record is public and would show that private repositories exist and how busy they are. The same split reaches the CPU. Under docker, every run gets `--cpu-shares 256`, a weight of about 10 against the default 100, except pushes to the default branch and the batch that lands next. A burst of pull runs leaves the site, deploys and main's runs the CPU they need. ## When a newer commit arrives By default a workflow is `superseded: cancel`. A newer commit's run of it on the same ref or pull cancels the older run, waiting or running, since the older result no longer matters. Cancelling kills the running step and its process group, skips the rest, and files the check as `cancelled`. A run cancelled while waiting is filed `cancelled` without starting. A full rerun of the same commit leaves the older run going, but a run asked for by name cancels the previous run of that workflow at the same commit, as both would file one check. Runs on different refs are left to finish. ### Deploys: `superseded: finish` A workflow that deploys can say `superseded: finish`. Such a run takes the workflow's turn on the ref or pull as soon as it is filed, if no run holds it. From then on it is kept: a newer commit's run does not cancel it. The newer run waits, as `queued`, for the turn, and a third commit's run cancels the one waiting, so at most one waits. Deploys go out in order, and the newest commit is the next to go. The turn is held until the check is filed as ended, so the next deploy files after it. A kept run is still cancelled when its result can no longer mean anything or its wait would be unbounded: its pull moves to another head or base, closes, or loses a branch; its merge-queue batch is dropped; or a stop runs out of time. A later cancel never overwrites one of these reasons. ## Stopping and starting again ### A stop `SIGTERM` or `SIGINT` stops the runner, and is heard even during startup. A stop: 1. waits for any pass that is filing or starting runs, and stops reading events; 2. cancels runs asked for by name, recording them to run again on start; 3. waits up to `--stop-wait` seconds (900) for every run to end, landing each merge-queue batch whose runs have then all passed ([Dropped runs](merge-queue.md#when-something-moves)); 4. cancels every run still going, and waits for those to end. A stop never waits forever on a hung step. One did once, when a deploy's `docker compose up` failed and nothing came to kill the runner. Since a stop costs the runs still waiting, and can cut off a merge-queue batch still being checked, which is then checked again, knotless.dev's deploys stop the runner only when what it runs changed: the sources it is built from, or its settings. Each deploy rebuilds the image, so the image alone cannot tell; when unsure, it restarts the runner ([When a deploy restarts the runner](../../deploy/README.md#when-a-deploy-restarts-the-runner)). A run that has not started its steps when the stop comes is dropped: its check is filed as `cancelled`, and the state file records it. The exception is a run at the same commit and base as a running `superseded: finish` run, since a deploy may wait for its own commit's other checks. It is kept until that run ends, and dropped then if still not started. On the next start, a dropped run runs again for only those workflows, and only if its ref or pull is still where it was. If it moved on, the newer commit runs all its workflows. A pull whose base alone moved runs all its workflows on the base as it is then: a runner landing the queue runs a pull again only as its head moves, so it would otherwise not run at all. A dropped batch run is not reported to the queue as a failure. ### A start It reads each repository its arguments name first. One it cannot read, gone or private to it, is logged (`failed`, with the reason) and passed over until the next start, so one bad name does not keep it from the rest. Only when it can read none of them, and has none to find without `--discover`, does it stop, with that reason. Before any run, the runner ends as `error` every check of a watched repository that its account left `queued` or `running`, saying the runner stopped before the run started or finished. `knotless rerun` runs it again. It also removes leftover containers: those labelled with its DID, and, with `--work`, those mounting a path under the work directory. It does this again every `--sweep`, sparing the containers of its own runs. One was once left running for seven hours. It never touches other containers. ### The state file The state file (`--state`) is JSON, written atomically. A file that cannot be read, or is corrupt, counts as empty, and every repository is then watched as if for the first time. Each field is keyed by repository: | Field | Holds | | --- | --- | | `repos` | each ref's generation last run | | `pulls` | each open pull's head and base last run at, or passed over at | | `reruns` | the newest rerun request acted on | | `asked` | pulls asked for and not yet run | | `asked_only` | of those, the ones asked for only some workflows, by name | | `passed` | trusted pull and batch runs that passed, for reuse, at most 256 | | `dropped` | the workflows dropped at a stop, per ref, pull or queue, and commit and base | | `wanted` | the workflows asked for by name and not yet run, per ref, with their variables | | `used` | the milliseconds each owner's public repositories' runs' steps took, per day, for the last 30 days | | `used_private` | the same for private repositories | Two more are the runner's own, not by repository: `discovered`, the repositories found naming the runner, watched again at a start before the appview is asked, and `invited`, where the reading of invites ended. ## Where a step runs Each run gets a temporary directory under `--work`, holding a clone of the mirror at the commit, the log, and an `outputs/` directory, all removed afterwards. The clone hard-links objects rather than using alternates, so git works inside a container that sees only the checkout. Under the `docker` executor, the command line's default (the library's `Options::new` defaults to `shell`), each run gets one container from the workflow's `image`, or `--image`. The checkout is mounted at `/work`. Each step is a `docker exec` of `sh -ec