The worker runs these four stages for each active ticket. The process config gives the desired state. GitHub, read by polling, gives the observed state.
The four stages
Observe
Poll GitHub for the issue's labels and assignees, plus branches, PRs, reviews and check runs.
Evaluate
Load the current step from the database. Check its postconditions, or the gate outcome, with the predicate library.
Act
Advance to next, spawn an agent under the ticket lease, wait at a gate, or retry and escalate on failure.
Project
Write labels, assignees and comments back to the issue when the state changed. Append every change as an event.
Desired and observed state
The process gives the desired state: which step the ticket is in and what must hold before it moves on. GitHub gives the observed state: labels, assignees, branches, pull requests, reviews and check runs. Each pass compares the two and acts on the difference.
One worker acts on a ticket at a time. A run starts once for each attempt of a step, so a repeated pass cannot start a second agent.
Predicates decide, not the agent
A step moves on when its postconditions hold. A postcondition combines predicates from a closed library. You join them with all, any and not. The config can only name a predicate. A new predicate needs a code change.
Factorial trusts the predicates and never the report of the agent. Processes shows how to write a condition.
| Predicate | Holds when |
|---|---|
branch_exists(name) | A branch matches the name. |
file_exists(path, ref) | A branch that matches the ref holds a file that matches the path. |
pr_open(head, base) | A matching pull request is open and not merged. |
pr_merged(head, base) | A matching pull request has merged. |
pr_approved(head, base, by?) | A matching pull request is open and has an approval at its current head. A merged or closed pull request never counts. |
pr_changes_requested(head) | A matching open pull request has a reviewer whose latest review asks for changes. |
ci_green(ref) | Every matching branch has check runs, and all of them passed. A branch with no check runs does not count. |
ticket_label(name) | The issue has exactly that label. |
ticket_has_assignee() | The issue has at least one assignee. |
issue_comment_contains(marker) | The App's own bot wrote a comment on the issue that contains the marker. |
pr_comment_contains(head, marker) | The App's own bot wrote such a comment on a matching pull request. |
Branch names, pull request refs and file paths accept *. This lets a predicate match the part of a branch name that the agent chooses.
When a person changes a ticket
Factorial observes every change to the labels and the assignees of a ticket. It never treats a change as a command that skips the process. Three labels resume or re-enter a ticket when you remove them:
factorial:interruptedresumes the session of the agent in the workspace that the interrupt kept.factorial:waitingdoes the same after an idle session parks.factorial:exitgives the ticket a fresh attempt at the step it left.
A session that parks at its time limit is the exception. Only the Resume button in the dashboard releases it. Removing factorial:blocked retries the step that failed. Tickets and gates shows how to interrupt and resume a run.