Repository navigation
Description
Stale PR clock resets on bot activity and never closes
Problem
PR #28 has had no author activity since May. The stale workflow warned about closing it on Aug 29. The PR is still open on Oct 17.
Two things cause this:
actions/staleusesupdated_atas its clock. The defaultremove-stale-when-updated: trueremoves the stale label on any update.label-pr-review-state.ymlupdates the PR many times. It posts the review-status comment. It flipsawaiting-authorandhas-conflictsevery few hours. Each update movesupdated_at.
The Aug 30 stale run (run 33292774382, job "60-day inactivity") shows the cause:
[#28] Found this pull request last updated at: 2026-08-29T22:04:11Z
[#28] Comments that are not the stale comment or another bot: 0
[#28] Pull request has been updated since it was marked stale: true
[#28] Remove the stale label since the pull request has been updated ...
[#28] The label "stale-inactive" was removed
22:04:11Z is the time of the review-status comment from label-pr-review-state.yml. The action ignores bot comments in its comment check. It does not ignore them in its updated_at check. The pinned source (4391f3da, v11.0.0, src/classes/issues-processor.ts, _processStaleIssue) matches this log.
label-pr-review-state.yml removes stale-awaiting-author. It never removes stale-inactive.
Formal model
PrStale.tla (below) models one PR. The author clock is the ground truth. Three policies are compared:
- A: today.
actions/staledefaults. - B:
actions/stalewithremove-stale-when-updated: false. - C: the review workflow owns the clock. Only author activity resets it.
Run with TLC 2.19. Constants: StaleAfter = 2, Grace = 1. Each run explores fewer than 100 states.
| Property | A (today) | B (config only) | C (proposed) |
|---|---|---|---|
I1 NoFalseClose |
pass | fail | pass |
I2 StaleClearsOnActivity |
pass | fail | pass |
L1 SilentAuthorCloses |
fail | fail | pass |
- A fails L1. The bot update clears the label and resets the clock. The cycle repeats forever.
- B fails I1. The label stays after the author acts. The action closes the PR once
updated_atis 7 days old. An author who acted 7 days ago gets no full window. - B fails L1. The action closes a stale PR only if
updated_atis older than the grace period. Hourly bot updates keepupdated_atfresh, so the PR never closes. - A passes I2 only because the model clears the label at once. The real action clears it on its next daily run. This favors A.
The A and B close rules follow the pinned actions/stale source: close when the stale label exists, no user comment came after the label, and updated_at is older than days-before-pr-close. The model clears the label at once in A after author activity. The real action does it on its next daily run. This favors A. Test policy C with workflow_dispatch and debug-only: true before you rely on it.
Invariants for the fix
- I1. The workflow never closes an exempt PR. It never closes a PR when the author acted inside the window.
- I2. After author activity, the stale label goes away. The exception is when the author is silent again.
- L1. If the author stays silent and the PR is not exempt, the PR closes.
- Close re-check. The close decision reads the author clock. It does not read the label age.
- Bot silence. Bot comments and label changes never change the clock.
Proposed change
- Add one step to
label-pr-review-state.yml. It computes the last author activity for each PR. Author activity is: the latest commit by the author, the latest comment or review by the author, and the latestready_for_reviewevent. - Add stale labeling and closing to the same step. Use the thresholds from
stale.yml: 60 days, 14 days withawaiting-author, and 7 days grace. Keep the exempt labels. - Remove
stale-inactiveandstale-awaiting-authorwhen the author clock is inside the window. - Remove
stale.yml, or run it withdebug-only: trueuntil the new step is proven.
Acceptance
- A PR with no author activity closes after the stale period, even when the bot updates it every hour.
- A PR with new author activity never closes in the same run.
- Exempt PRs never close.
- Add a unit test for the author-clock function.
PrStale.tla
---- MODULE PrStale ----
\* Model of the PR stale/close clock. One PR, time in whole days.
\* Policy "A" = today (actions/stale defaults).
\* Policy "B" = actions/stale with remove-stale-when-updated: false.
\* Policy "C" = clock owned by the review workflow, reset by author activity only.
EXTENDS Naturals
CONSTANTS Policy, StaleAfter, Grace
Max == StaleAfter + Grace + 1
Min(a, b) == IF a < b THEN a ELSE b
VARIABLES state, \* "open" or "closed"
aAge, \* ground truth: days since last AUTHOR activity
age, \* days since the clock the policy uses last reset
stale, \* a stale-* label is on the PR
staleDays, \* days since the label was added (policies A and B)
exempt \* do-not-close, pinned or work-in-progress label
vars == <<state, aAge, age, stale, staleDays, exempt>>
TypeOK == /\ state \in {"open", "closed"} /\ aAge \in 0..Max /\ age \in 0..Max
/\ stale \in BOOLEAN /\ staleDays \in 0..Grace /\ exempt \in BOOLEAN
Init == /\ state = "open" /\ aAge = 0 /\ age = 0
/\ stale = FALSE /\ staleDays = 0 /\ exempt = FALSE
Tick == /\ aAge' = Min(aAge + 1, Max)
/\ age' = Min(age + 1, Max)
/\ staleDays' = IF stale THEN Min(staleDays + 1, Grace) ELSE staleDays
/\ UNCHANGED <<state, stale, exempt>>
\* Commit, author comment, or ready-for-review.
Author == /\ state = "open"
/\ aAge' = 0 /\ age' = 0
/\ IF Policy = "A" THEN stale' = FALSE /\ staleDays' = 0
ELSE UNCHANGED <<stale, staleDays>>
/\ UNCHANGED <<state, exempt>>
\* Review-status comment or label flip by the bot. Moves updated_at only.
Bot == /\ state = "open"
/\ \/ Policy = "A" /\ age' = 0 /\ stale' = FALSE /\ staleDays' = 0
\/ Policy = "B" /\ age' = 0 /\ UNCHANGED <<stale, staleDays>>
\/ Policy = "C" /\ UNCHANGED <<age, stale, staleDays>>
/\ UNCHANGED <<state, aAge, exempt>>
MarkStale == /\ state = "open" /\ ~stale /\ ~exempt /\ age >= StaleAfter
/\ stale' = TRUE /\ staleDays' = 0
/\ UNCHANGED <<state, aAge, age, exempt>>
\* Policy C only: the review workflow removes the label after author activity.
ClearStale == /\ Policy = "C" /\ state = "open" /\ stale /\ age < StaleAfter
/\ stale' = FALSE /\ staleDays' = 0
/\ UNCHANGED <<state, aAge, age, exempt>>
Close == /\ state = "open" /\ stale /\ ~exempt
/\ IF Policy = "C" THEN age >= StaleAfter + Grace ELSE staleDays >= Grace /\ age >= Grace
/\ state' = "closed"
/\ UNCHANGED <<aAge, age, stale, staleDays, exempt>>
ToggleExempt == /\ exempt' = ~exempt /\ UNCHANGED <<state, aAge, age, stale, staleDays>>
Reopen == /\ state = "closed" /\ state' = "open" /\ aAge' = 0 /\ age' = 0
/\ stale' = FALSE /\ staleDays' = 0 /\ UNCHANGED exempt
Next == Tick \/ Author \/ Bot \/ MarkStale \/ ClearStale \/ Close \/ ToggleExempt \/ Reopen
Spec == Init /\ [][Next]_vars /\ WF_vars(Tick) /\ WF_vars(MarkStale)
/\ SF_vars(ClearStale) /\ WF_vars(Close)
\* I1: never close an exempt PR, or one whose author acted inside the window.
NoFalseClose == [][(state = "open" /\ state' = "closed") =>
(~exempt /\ aAge >= StaleAfter + Grace)]_vars
\* I2: after author activity the label goes away, unless the author is silent again.
StaleClearsOnActivity == (state = "open" /\ stale /\ aAge < StaleAfter)
~> (~stale \/ state = "closed" \/ aAge >= StaleAfter)
\* L1: if the author stays silent and the PR is not exempt, it closes.
SilentAuthorCloses == (<>[](aAge >= StaleAfter) /\ <>[](~exempt)) => <>(state = "closed")
====
Activity