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.

The PR state panel on a Last Light run detail page: head SHA f50ffd4232cc, attempt 2 of 3, checks passing, 3 settled checks, base checks passing, 0 of 2 flaky deferrals, $0.59 spent on this problem, prior diagnosis reproducible, a mayMerge decision of true with the reason 'checks-passing: 3 settled check(s), all green', the attempt-one marker line, the push gate box and a PR journal note
The recorded snapshot on a run's detail panel. Everything described on this page, on one real pull request — including the decision the run was handed and the sentence explaining it.
There is no status column on a pull request. The state is re-derived on every dispatch — live reads from GitHub, folded together with the PR's own run history. That is why removing a label or pushing a commit changes the answer immediately, with nothing to migrate and nothing to clean up by hand.

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.

GitHub webhook @last-light comment cron fan-out Slack /api/run admin retry resolvePrState one snapshot per dispatch resolveDispatchDisposition pure — no I/O run skip …with a reason

The snapshot has two halves. The first is live from GitHub — what is true about the pull request at this instant:

FieldWhat it records
headSha, headAuthor, headIsOursThe head commit and who wrote it. headIsOurs is what tells our own fix commit apart from a maintainer's push
headRef, baseRefThe PR's real base, not the repository's default branch
isDraft, isFork, headRepoFullNameWhether it can be reviewed, and whether there is a branch we could push to at all
labels, title, bodyRead once, used by every guard and projected into the prompts
checksState, settledCheckCountpassing / failing / pending / none, and how many checks actually settled. None is not passing
baseChecksStateWhether the base branch is itself red — the live fact behind upstream-broken
botReviewAtHeadOur own review at this exact commit, if any
ciReportThe 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:

FieldWhat it records
attempt, priorAttemptsWhich attempt this is, and one bounded line describing each earlier one
priorDiagnosisClassWhat the previous attempt concluded — the only prior verdict any gate reads
flakyDeferralsConsecutive flaky verdicts, so “flaky” cannot be free forever
notesThe PR journal — what the agent chose to remember, across every PR-scoped workflow
escalatedAtShaThe commit one of our runs gave up at. This is the escalation, not the label
forkNoticedAtShaThat we have already explained, once, that we cannot push to a fork
interventionThe last time a human said “try again” — when, at which commit, and how the ask arrived
costBaselineUsd, cumulativeCostUsdSpend on the current problem, not over the PR's lifetime
assessedHeadShaByWorkflowWhich workflow has already handled which commit — the idempotency record
runInFlightWhether another Last Light run already owns this PR
readErrorsWhich 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.

FunctionAnswers
resolveDispatchDispositionMay 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
mayMergeMay this PR be landed at all, by either mechanism? Gates the action, not the dispatch
renderContextProjects 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.

the PR read failed read-degraded hold label applied on-hold another run in flight run-in-flight the PR is from a fork fork-pr the base branch is red upstream-broken we already escalated escalated cost budget spent budget-exhausted attempt budget spent attempts-exhausted last class not retryable not-retryable this head already assessed already-assessed run attempt N of max

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.

ExitWhyWhat lands on the PRWhat clears it
read-degradedWe could not read the PR, so every guard below would be reading defaultsNothingThe next event, or a cron tick
on-holdA maintainer applied the hold labelNothing — except one reply if a human asked directlyRemoving the label
run-in-flightAnother Last Light run already owns this PRNothing — except a reply to whoever askedThat run finishing; a cron picks it up
fork-prThe head branch is not on this repositoryOne comment, once per PRNothing — it is a fact about the PR
upstream-brokenThe base branch is red, so a fix here cannot make CI greenNothingThe base going green
escalatedWe already gave up at this commitNothing — it was said once alreadySomeone else's push, or a retry
budget-exhaustedfix.maxCostUsd spent on this problemrequires-human + one commentA push or a retry
attempts-exhaustedfix.maxAttempts spentrequires-human + one commentA push or a retry
not-retryableThe last diagnosis names a class no further attempt can help withrequires-human + one commentA push or a retry
already-assessedA run has already handled this exact commitNothingA 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.

There is no backoff and no cooldown here. Nothing sleeps and retries later on a timer. What paces the loop instead is the per-commit dedup (a given head SHA is assessed once), the attempt and cost budgets, and the cron schedules below. A guard that says “come back later” means literally that: the next event, or the next sweep.

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.

The loser is dropped, not queued. That is only sound because every dropped case is picked up again by a cron — which is why the three sweeps below still run even when webhooks are configured. Turning drop-on-conflict into queue-on-conflict is a prerequisite for retiring any of them.

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 crashed run spends nothing Eligible attempt N of max Diagnose classify the failure Fix + local gate reproducible · env-mismatch Push green gate only CI re-runs settles again still red → attempt N+1 flaky · upstream-broken no attempt spent — back to eligible infra-dependent or the budget is spent Escalated requires-human + one comment a push, a retry, or removing the label re-arms the window On hold — nothing runs at all

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 commitWho authored itAttempt
unchanged+1 — we made no progress
changedus+1 — our fix landed and CI is still red; same problem
changedanyone elsereset 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.

SurfaceHow it is authorisedNotes
Push a commit to the branchGitHub write accessNo 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 commandParsed 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 labelGitHub already requires triage permission to change a labelNo 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#42An authenticated admin sessionThe 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.

To stop Last Light touching a PR at all, apply the hold labellastlight-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.

PR event or sweep every route resolveReviewTrigger the only reader of review.trigger dispatch check: in_progress defer check: queued / neutral skip no check at all
DecisionWhenlast-light/review
dispatchAn explicit request or the request label; eager on PR attention; a settled check suite under after-checksin_progress, completed from the run's terminal transition
deferon-request with nobody asking; after-checks still waiting for CI; or reached on PR attention rather than a settlequeued, or neutral under on-request — and only on a PR-attention event
skipA draft, a commit we already reviewed, a push that changed only generated files since the review we posted, or another run in flightNothing — 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.

CI settles red pr.checks_failed dependabot-ci-fix diagnose → fix → push CI re-runs still red → attempt N+1 green CI settles green pr.checks_passed dependabot-pr-merge assess the bump mayMerge settled + passing auto-merge (squash) leave it for a human fix-red-dependency-prs · daily 15:00 merge-green-dependency-prs · daily 14:00

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.

CronScheduleWhat 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-prsdaily, 14:00Green dependency PRs that went green before boot or during a missed webhook delivery
fix-red-dependency-prsdaily, 15:00Red 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.