Docs
The PR state model
A pull request is the unit of a release workflow. It is where CI, code review, dependency updates and merge policy all meet — so the moment you put automation on it, several actors are working the same pull request at once: a fix run, a reviewer, a merge assessor, a nightly sweep, and a human maintainer. They only compose if they all agree on one answer to “what is the state of this PR right now, and what have we already tried?”
When they don't agree, the failures are the ones teams actually report: two bots cloning and pushing the same branch; the same “I gave up” comment posted again on every push; a review of a tree that a fix run is rewriting underneath it, stale before it lands; a dependency PR that quietly stopped being worked on with nothing on it to say so — and no way for a maintainer to tell still working on it from gave up three days ago. You cannot automate a release workflow on top of state that every reader derives for itself.
So Last Light resolves it once. Every route — a webhook, an
@last-light comment, a cron sweep, the API — passes through one
choke point that builds a single snapshot of the pull request, and every
policy question after that is a pure function over the snapshot. One answer
per PR, one reason per refusal, and that reason rendered identically in the
log line, in the comment on the PR and in the dashboard.
One snapshot, resolved once
Before this existed, what the harness “knew” about a pull request was read from six different places, each fetching an overlapping subset and each free to disagree, over state scattered across seven stores — labels, a check run, the run's context, the run's scratch space, execution status, live GitHub reads, and a file in the sandbox. Three shipped bugs came directly out of that, including a concurrency guard that had never once matched a row, so nothing had ever stopped two agents pushing to the same branch.
Now a single PrState is resolved at the one point every route
crosses, and rides down onto the run so nothing is fetched twice.
The snapshot has two halves. The first is live from GitHub — what is true about the pull request at this instant:
| Field | What it records |
|---|---|
headSha, headAuthor, headIsOurs | The head commit and who wrote it. headIsOurs is what tells our own fix commit apart from a maintainer's push |
headRef, baseRef | The PR's real base, not the repository's default branch |
isDraft, isFork, headRepoFullName | Whether it can be reviewed, and whether there is a branch we could push to at all |
labels, title, body | Read once, used by every guard and projected into the prompts |
checksState, settledCheckCount | passing / failing / pending / none, and how many checks actually settled. None is not passing |
baseChecksState | Whether the base branch is itself red — the live fact behind upstream-broken |
botReviewAtHead | Our own review at this exact commit, if any |
ciReport | The failing jobs and their logs, fetched only when the checks are red |
The second half is derived from our own history, folded from the previous runs on this pull request — not on this workflow:
| Field | What it records |
|---|---|
attempt, priorAttempts | Which attempt this is, and one bounded line describing each earlier one |
priorDiagnosisClass | What the previous attempt concluded — the only prior verdict any gate reads |
flakyDeferrals | Consecutive flaky verdicts, so “flaky” cannot be free forever |
notes | The PR journal — what the agent chose to remember, across every PR-scoped workflow |
escalatedAtSha | The commit one of our runs gave up at. This is the escalation, not the label |
forkNoticedAtSha | That we have already explained, once, that we cannot push to a fork |
intervention | The last time a human said “try again” — when, at which commit, and how the ask arrived |
costBaselineUsd, cumulativeCostUsd | Spend on the current problem, not over the PR's lifetime |
assessedHeadShaByWorkflow | Which workflow has already handled which commit — the idempotency record |
runInFlight | Whether another Last Light run already owns this PR |
readErrors | Which GitHub reads failed while building the snapshot |
Resolving a snapshot never throws. Every read is independently best-effort and degrades to a value that cannot cause a skip, because a bad minute at GitHub should not silently stop the bot. There is exactly one exception, and it is deliberate: if reading the pull request itself fails, all of its degraded values are the permissive ones — no labels, not a fork, no head SHA — so the snapshot would look perfectly healthy while we know nothing at all. That one failure is treated as “come back later” and outranks every other guard.
Every decision is a pure function over it
Once the snapshot exists, no policy question needs I/O. Each of these takes
the snapshot plus the run's already-resolved config and returns
{ decision, reason, inputs } — never a bare enum.
| Function | Answers |
|---|---|
resolveDispatchDisposition | May this workflow run against this PR right now? The one gate every route crosses |
resolveFixDisposition | …for a fix run (PR fix or Dependabot CI fix) |
resolveMergeDisposition | …for Dependabot PR merge |
resolveReviewTrigger | …for PR review — and it is the only implementation of review.trigger anywhere |
mayMerge | May this PR be landed at all, by either mechanism? Gates the action, not the dispatch |
renderContext | Projects the snapshot into the variables the prompts render |
The reason is produced by the decision, not reconstructed afterwards by whoever is displaying it. That is the whole point of returning a sentence beside the verdict: the log line, the comment posted on the pull request and the dashboard panel are three renderings of one string, instead of three prose variants that drift apart. Being pure also means the entire gate is testable against literal fixtures — no GitHub mock, no sandbox.
The gate, in order
Every PR-scoped dispatch walks the same ladder and takes the first exit that applies. The ordering is not cosmetic — several of these are deliberately above others so that a PR is never labelled for a problem that is not the one blocking it.
Colour is the kind of exit, and it is the part worth remembering:
grey leaves nothing at all on the pull
request, gold says something exactly once,
and orange applies
requires-human and posts one comment.
| Exit | Why | What lands on the PR | What clears it |
|---|---|---|---|
read-degraded | We could not read the PR, so every guard below would be reading defaults | Nothing | The next event, or a cron tick |
on-hold | A maintainer applied the hold label | Nothing — except one reply if a human asked directly | Removing the label |
run-in-flight | Another Last Light run already owns this PR | Nothing — except a reply to whoever asked | That run finishing; a cron picks it up |
fork-pr | The head branch is not on this repository | One comment, once per PR | Nothing — it is a fact about the PR |
upstream-broken | The base branch is red, so a fix here cannot make CI green | Nothing | The base going green |
escalated | We already gave up at this commit | Nothing — it was said once already | Someone else's push, or a retry |
budget-exhausted | fix.maxCostUsd spent on this problem | requires-human + one comment | A push or a retry |
attempts-exhausted | fix.maxAttempts spent | requires-human + one comment | A push or a retry |
not-retryable | The last diagnosis names a class no further attempt can help with | requires-human + one comment | A push or a retry |
already-assessed | A run has already handled this exact commit | Nothing | A new head commit |
Two orderings carry most of the design. The failed read sits above the
hold, because an unreadable PR reports no labels — so on that path we
do not know whether the hold is even there, and “we could not read
it” outranks every reading of it. And the three escalating exits
sit below every other guard, so a fork PR whose budget happens to be
spent is never labelled requires-human for a problem its author
could not fix anyway.
An explicit request from a maintainer — an @last-light comment, a
review request, the API — overrides exactly three of these:
escalated, not-retryable and
already-assessed. Asking directly is an intentional override of
policy. It does not override the facts: a fork PR, a red base
branch and a spent budget do not care how nicely you ask, and the hold beats it
outright.
Deciding and acting are separate
The functions above only decide. A second, shared step applies the verdict, and it is what keeps a nightly sweep and a live webhook behaving identically: log the decision, return silently for a hold, a lock drop or a degraded read, record a retry ask that could not be served yet, and otherwise escalate. The courtesies that need a human on the other end — the reply to a dropped request, the fork notice, the API's status code — belong to the route that has one, not to the decision.
One run per pull request
Only one Last Light run may be in flight for a given PR at a time, across every workflow that declares itself PR-scoped — packaged, that is PR fix, Dependabot CI fix, Dependabot PR merge and PR review. The set is derived from each workflow's own definition rather than a hardcoded list, so an overlay that forks one of them keeps the guard.
This closes cases that were genuinely reachable before: an
@last-light fix this comment running concurrently with the nightly
sweep's own fix run — two agents, two clones of one branch, both pushing — and
auto-merge being enabled on a PR whose fix run was still writing its results.
A run paused on an approval gate still holds the lock, and the oldest run
wins.
How a fix attempt is counted
attempt is scoped to a problem, not to a pull
request and not to a workflow. The two fix workflows share one counter, one
cost window and one workspace per PR, because “how many times have we
tried to fix this?” is a fact about the PR — which of the two ran is an
implementation detail of how the event arrived.
A diagnosis lands in one of five classes, described in full on the
PR fix page. What matters here is which of
them cost an attempt: reproducible,
env-mismatch and infra-dependent do;
flaky and upstream-broken do not, because they are
verdicts about something other than this PR's code. Repeated
flaky verdicts are bounded separately — after
fix.maxFlakyDeferrals in a row the job is not flaky, it is
intermittently really failing, and the next run attempts it normally.
An attempt is only counted when a diagnosis actually landed. A
run that died on a sandbox failure, a quota rejection or a model API error
costs nothing. This is the single most important robustness rule in the design:
without it, one bad hour would escalate every open dependency PR across every
managed repository to requires-human, and a human would have to
un-stick each one by hand.
Whether the next event is the same problem is decided once, and it decides everything:
| The head commit | Who authored it | Attempt |
|---|---|---|
| unchanged | — | +1 — we made no progress |
| changed | us | +1 — our fix landed and CI is still red; same problem |
| changed | anyone else | reset to 1 — the world moved; fresh problem |
The third row is what makes a maintainer's push, a Dependabot rebase or a Renovate recreate re-arm the loop with nothing to clean up. Resetting on authorship alone would be wrong in the other direction — an attempt that pushes nothing leaves the head unchanged, so it would reset forever.
The same boundary moves the cost window, deliberately: the
attempt counter and fix.maxCostUsd bound the same window, so a
push has to re-arm both or neither. Re-arming only the counter is how the bot
once came to post a fresh “I gave up” comment on every push — a
comment whose own closing line tells the maintainer that pushing is the remedy.
That makes fix.maxCostUsd a futility guard, not a spend
guard: it is scoped to a problem and re-armed by any human
intervention.
Escalation, and the four ways out
When a skip is terminal for the problem — the attempts are
spent, the cost ceiling is reached, or the last diagnosis names something no
further attempt can fix — staying silent is worse than saying so. Those three
exits apply requires-human and post one comment
naming which case it was, how many attempts were spent, and what each of them
found.
The label is a notification, and nothing reads it. The state is the recorded fact that one of our runs escalated at a particular commit. So the run row is written first, before the label, and the comment is posted only behind a label that actually landed — a failed label write then costs the PR its notification, never its guard. Being once-only is a property of that record, not of scanning the PR for a comment we might have posted before.
There are four ways to say “go again”, and they all do exactly the same thing: re-arm the attempt counter and the cost window for a fresh full budget.
| Surface | How it is authorised | Notes |
|---|---|---|
| Push a commit to the branch | GitHub write access | No label to remove. A commit we pushed doesn't count — it is someone else's change that says the situation moved |
@last-light retry [reason] | The maintainer check that gates every @last-light command | Parsed before any classifier — a retry is an instruction, not something to guess at. The free text becomes a hint for the next attempt |
Remove the requires-human label | GitHub already requires triage permission to change a label | No webhook. It is inferred next time we look: we escalated at this head, the head hasn't moved, and our label is gone |
lastlight pr retry owner/repo#42 | An authenticated admin session | The only surface with no GitHub event behind it — so it is the only one that also dispatches straight away rather than waiting |
One asymmetry is deliberate: a retry keeps what earlier attempts learned and marks the seam, where a push wipes it. A push changed the code, so prior findings may be stale; a retry changed nothing but patience, and throwing the journal away would send attempt 1 of the new window straight back down attempt 1 of the old window's road.
Retries are unbounded — each one buys a full window, which makes it a real spending decision, so who asked, when and why is recorded on the run that follows. A retry also consumes the escalation record, which is what bounds the mechanism: the next stop writes a fresh one.
A retry does not override the hold, the fork guard, the run lock, a red base branch, or a degraded read. Those are facts about the pull request, or instructions that outrank it; the budgets are policy, and policy is what a maintainer may move.
lastlight-ignore by default, configurable as
hold.label. It blocks every workflow on any issue or pull
request carrying it, outranks an explicit request (which earns one reply
naming the label), and lifts the moment you remove it with no record to
clear. It is the opposite of a retry, and it beats all four of them.
When a review runs
review.trigger has exactly one implementation, crossed by every
route — webhook, comment, label, sweep, Slack and CLI — so the mode means the
same thing everywhere. Discovery and policy are separate concerns now: the
cron finds candidates, the resolver decides.
| Decision | When | last-light/review |
|---|---|---|
dispatch | An explicit request or the request label; eager on PR attention; a settled check suite under after-checks | in_progress, completed from the run's terminal transition |
defer | on-request with nobody asking; after-checks still waiting for CI; or reached on PR attention rather than a settle | queued, or neutral under on-request — and only on a PR-attention event |
skip | A draft, a commit we already reviewed, a push that changed only generated files since the review we posted, or another run in flight | Nothing — except the generated-only case, which repeats the standing verdict on the new head. A run that never dispatches must otherwise not create a check and immediately conclude it |
The check run only exists when review.postsCheck is on (off by
default). When it does, it is a projection of run state:
created at the same choke point that decided to run, and concluded from the
run's terminal transition — so a deploy mid-review, a resumed run, an expiry or
a crash all resolve it, instead of leaving it stranded in_progress
forever.
A push with nothing new to say does not earn a review.
“We already reviewed this exact commit” used to be the only
suppression there was — so a lock file re-derivation, being a new commit,
bought a second formal review saying the same thing. Now, when every path
changed since the review we posted matches
review.generatedPaths, the
resolver skips. It measures from the review we posted rather than the last
commit we ran against, so a run that declined to post can't suppress the
change it never reviewed; and an unreadable or truncated diff dispatches.
This is the one skip that still leaves a check, because it is the only one
that would otherwise strand a required check on a commit nobody reviewed.
Two more behaviours are worth knowing. An explicit
@last-light review always dispatches, overriding the
mode, the draft skip and the “already reviewed this commit” check —
the one thing it does not override is the run lock, because that is a physical
constraint rather than a policy. And fix outranks review: a
check suite that settles red on a fix-eligible PR becomes a fix, not a review,
because reviewing a tree that is about to be rewritten produces a review that
is stale before it lands.
When a dependency PR is merged
The two dependency workflows are one lane. A red PR goes to Dependabot CI fix, which repairs it and pushes; once CI is green, ownership passes to Dependabot PR merge, which is the single owner of the merge decision.
mayMerge is the one predicate that decides whether a PR may be
landed at all: the head commit's checks must have settled
passing, and at least
dependencies.minSettledChecks of them must exist. It is
deliberately not a dispatch gate — the merge run still needs
to start in order to label and comment — so it is evaluated once and handed to
the run as a decided verdict with its reason attached, which the prompt reads
rather than re-deriving. The dispatch gate only refuses the cheap case: checks
still pending have nothing to decide yet.
One asymmetry between the fix and merge routes is load-bearing. On the fix route, a commit we authored on top of an escalation is still the same problem — otherwise the loop never terminates. On the merge route it is the resolution: the whole hand-off ends with our fix commit at the head. Carrying the fix route's rule across made that hand-off structurally unreachable for any PR we had ever escalated — we fixed it, CI went green, and the merge route skipped because we were the one who fixed it.
The crons are the re-pickup net
Because a dropped dispatch is never queued, something has to come back for it. Three sweeps do, and unlike most cron backstops they run alongside webhooks, not instead of them.
| Cron | Schedule | What it releases |
|---|---|---|
check-prs-awaiting-review | every 30 min | The PRs whose fix chain ended without pushing — attempts exhausted, infra-dependent, a flaky deferral, a red base, a crash. No new commit exists, so no further check suite will ever fire for them, and they are exactly the PRs a human most wants reviewed. It also releases PRs whose checks will never settle at all, and picks up anything dropped by the run lock |
merge-green-dependency-prs | daily, 14:00 | Green dependency PRs that went green before boot or during a missed webhook delivery |
fix-red-dependency-prs | daily, 15:00 | Red dependency PRs — the route that reaches most exhausted PRs, which is why escalation is one shared code path rather than a webhook-only behaviour |
The discoverers hold no policy. They find candidates — open PRs in managed repositories — and every one of them then crosses the same gate as a webhook, with the same snapshot and the same reasons. That is what makes a nightly sweep and a live delivery produce byte-identical context, which they did not before.
Where to see it
The whole snapshot is persisted on the run, so every decision is auditable
after the fact. The dashboard's run detail panel — shown at the top of this
page — renders it beside the phase timeline: the resolved facts, each
{decision, reason} pair, the per-attempt marker lines,
the push gate the agent wrote for itself, and the PR journal. From a terminal,
lastlight pr retry owner/repo#42 [reason] is the fourth re-arm
surface; see the CLI reference.
The normative contract — field by field, guard by guard — lives in the specification, under Router & dispatch.