Skip to content

Stale PR clock resets on bot activity and never closes #2076

Closed

Description

@edelauna

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:

  1. actions/stale uses updated_at as its clock. The default remove-stale-when-updated: true removes the stale label on any update.
  2. label-pr-review-state.yml updates the PR many times. It posts the review-status comment. It flips awaiting-author and has-conflicts every few hours. Each update moves updated_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/stale defaults.
  • B: actions/stale with remove-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_at is 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_at is older than the grace period. Hourly bot updates keep updated_at fresh, 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

  1. I1. The workflow never closes an exempt PR. It never closes a PR when the author acted inside the window.
  2. I2. After author activity, the stale label goes away. The exception is when the author is silent again.
  3. L1. If the author stays silent and the PR is not exempt, the PR closes.
  4. Close re-check. The close decision reads the author clock. It does not read the label age.
  5. Bot silence. Bot comments and label changes never change the clock.

Proposed change

  1. 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 latest ready_for_review event.
  2. Add stale labeling and closing to the same step. Use the thresholds from stale.yml: 60 days, 14 days with awaiting-author, and 7 days grace. Keep the exempt labels.
  3. Remove stale-inactive and stale-awaiting-author when the author clock is inside the window.
  4. Remove stale.yml, or run it with debug-only: true until 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions