Skip to content

Budget breaker: close the gaps found in review before it is armed (race with a human pause, pagination, hold on a bad timestamp, lost notice, live API checks) #2251

Open

Description

@don-petry

Why

"Budget breaker writer" (#2153) merged disarmed on 2026-10-11 as a44bb9bb. Its reviews (CodeAnt and the QA advisory on the pull request) found gaps that do not matter while it only logs, and do matter the moment it is armed. This issue collects them so arming ("Auto-pause companion", #1565) has a concrete checklist. Do not arm before this is closed.

Code and tests

  1. Narrow the window in which a human pause can be claimed. If a maintainer sets AGENTS_PAUSED=true between the breaker's last read and its writes, the trip records the breaker as the owner, and a later run resumes a pause a human set. On a trip, after writing the source and until values and BEFORE setting the pause, read the state again; if the fleet is already paused, remove the two values just written, write nothing else, and warn. Test both orders of the race.
  2. Pagination. The organisation variable list is read 30 per page and the test stub returns one page. Add a test where AGENTS_PAUSED is on the second page; if it goes unseen, an existing human pause reads as "not paused". Make the stub honour pagination rather than ignore the flag.
  3. The minimum hold when updated_at is missing or unparseable. Today the resume proceeds with no hold on that path and no test covers it. Decide the behaviour (recommended: treat the hold as not yet satisfied for one run, then resume, so a bad timestamp can delay a resume by one tick but never block it), implement it and test it.
  4. A failed notice. A run whose notice fails goes red; the next run holds and never re-posts, so the pause is announced nowhere. Have the next run post the missing notice once (keyed on the until value), or state in AGENTS.md that a red run is the only signal, and test whichever is chosen.
  5. Arming value. A GitHub Actions expression compares strings without regard to case, so BUDGET_BREAKER_ARMED=TRUE delivers the write credential to the step even though the script then refuses to write and drops it. This cannot be fixed in the expression. Make the script log one clear error when the variable is set to anything other than the exact string true or empty, and say in AGENTS.md that only lowercase true arms.

Live checks during the disarmed period (record the results on #1565)

These are mocked in the tests and must be confirmed against the real API by the maintainer or with a throwaway organisation variable, never with the pause variables themselves:

  1. A value-only PATCH of an organisation variable keeps its visibility.
  2. A PATCH updates the variable's updated_at.
  3. The variable list paginates as the code expects.
  4. The dedicated write credential can read, create, update and delete an organisation variable, and nothing else.
  5. With the read credential the workflow uses, dev-lead's quota markers read as trusted (the run summary reports markers counted and dropped; confirm a real marker is counted).

Acceptance criteria

  1. Items 1 to 5 are implemented with tests, and the existing breaker suites pass unchanged apart from the stub's pagination support.
  2. Items 6 to 10 are recorded on Auto-pause companion: usage-telemetry adapter + scheduled poller that sets/clears AGENTS_PAUSED from the Claude budget #1565 with the date and what was observed.
  3. AGENTS.md states the arming preconditions.

Related: #2153, #2221, #1565, and "AGENTS_PAUSED stops only dev-lead" (#2249), which should also land before a pause is relied on.

Activity

  1. github-actions commented on Oct 11, 2026

    @github-actions
    Contributor

    Thanks for filing such a thorough checklist from the review of the budget breaker writer. The issue already lists five code changes, five live API checks and the acceptance criteria. One question before work starts: for item 4 (the failed notice), do you prefer that the next run posts the missing notice once, keyed on the until value, or that AGENTS.md states a red run is the only signal?

  2. don-petry commented on Oct 11, 2026

    @don-petry
    CollaboratorAuthor

    The code half is merged; what remains here is for the maintainer

    Items 1 to 5 merged on 2026-10-11 as df06b7a4 ("fix(budget-breaker): harden the fleet pause writer before it is armed"), with one addition made during review: an armed run now removes the breaker's own ownership markers when it finds them on an unpaused fleet, so a leftover marker cannot make a later hand pause look like the breaker's own. BUDGET_BREAKER_ARMED=false disarms quietly; only lowercase true arms. The breaker is still disarmed.

    This issue stays open for acceptance criterion 2. Nothing below can be done by automation: it needs the real API and, for two of them, a deliberate drill.

    Live checks, from the original list (record date and result on "Auto-pause companion", #1565):

    • 6. A value-only PATCH of an organisation variable keeps its visibility.
    • 7. A PATCH updates the variable's updated_at.
    • 8. The variable list paginates as the code expects.
    • 9. The dedicated write credential can read, create, update and delete an organisation variable, and nothing else.
    • 10. With the read credential the workflow uses, dev-lead's quota markers read as trusted (the run summary reports markers counted and dropped).

    Added by the reviews of the hardening change:

    • 11. Save one real multi-page response for the organisation variable list and one for an issue's comments as test fixtures, or confirm by observation that a pause on the second page is seen. The tests prove this against a stub only.
    • 12. A drill in a test organisation or with throwaway variables: set a pause by hand while a trip is in progress, and confirm the breaker does not later resume it. Two windows remain that unit tests cannot close: a hand pause set between the guard's re-read and the pause write, and a hand pause set while a leftover marker still exists before the next armed run removes it.
    • 13. Confirm the workflow's concurrency group is what prevents a duplicate notice when two runs overlap; there is no test for it.

    Also still blocking arming: "AGENTS_PAUSED stops only dev-lead" (#2249, in review) and "Budget breaker depends on scheduled ticks that this repository barely receives" (#2259).

  3. don-petry commented on Oct 11, 2026

    @don-petry
    CollaboratorAuthor

    Live checks: what was observed on 2026-10-11, and exact commands for the rest

    Read-only observations below were made with the don-petry login (the same identity the workflow's read credential GH_PAT_DON_PETRY belongs to), at about 15:20 UTC. Nothing was written and no pause variable was touched.

    # Check State What was observed / what is needed
    6 Value-only PATCH keeps visibility maintainer commands below
    7 PATCH updates updated_at maintainer commands below
    8 Variable list paginates as the code expects observed The organisation has 7 variables, so the code's page size of 30 never pages today. With the same call at per_page=2, gh api --paginate … --jq '.variables[] | {name, visibility, updated_at}' returned all 7 across 4 pages (2, 2, 2, 1). The filter runs once per page and every variable arrives. Not observed: a real list longer than 30.
    9 Write credential can read, create, update, delete an organisation variable and nothing else maintainer commands below; needs BUDGET_BREAKER_PAT to exist
    10 dev-lead quota markers read as trusted to the read credential partly No dev-lead quota marker was posted in this repository in the last four days, and the one breaker run so far (38125345031) read 0 markers, so a real marker has not been counted yet. Supporting only: the 12 most recent status=rate-limited comments here are pr-review's advisory marker (deliberately not counted), posted by donpetry-bot, whose author_association reads MEMBER to this identity. Confirm on the first breaker run after a real dev-lead rate limit: quota markers read: N trusted, M dropped as untrusted with N ≥ 1.
    11 A multi-page response is handled (variables and issue comments) observed, small pages Variables: as item 8. Comments: gh api --paginate "repos/petry-projects/.github-private/issues/1565/comments?per_page=4" --jq 'length' printed 4 4 1, one count per page, which is what the notice lookup sums. Real responses were not saved as fixtures.
    12 Drill: a hand pause set during a trip is not later resumed maintainer, needs a test organisation See the note below: throwaway variables cannot be used.
    13 The concurrency group prevents a duplicate notice by configuration only budget-breaker.yml has concurrency: {group: budget-breaker, cancel-in-progress: false}, which GitHub documents as at most one running and one pending run per group. With one run in 14 hours there has been no overlap to observe.

    Items 6 and 7 (any login with organisation admin; uses a throwaway variable, removes it at the end)

    gh api -X POST orgs/petry-projects/actions/variables -f name=BUDGET_BREAKER_PROBE -f value=a -f visibility=private
    gh api orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE --jq '{value, visibility, updated_at}'
    sleep 5; gh api -X PATCH orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE -f value=b
    gh api orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE --jq '{value, visibility, updated_at}'
    gh api -X DELETE orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE

    Pass: the second read shows value: b, visibility: private unchanged (item 6), and a later updated_at (item 7).

    Item 9 (with the dedicated credential; do not paste its value anywhere)

    Put the token in the shell without echoing it or writing it to history, run the same five commands as above with it, then two calls that must be refused:

    read -rs "BBW?paste the write credential, then Enter: "; export BBW
    GH_TOKEN="$BBW" gh api orgs/petry-projects/actions/variables --jq '.total_count'
    GH_TOKEN="$BBW" gh api orgs/petry-projects/actions/secrets --jq '.total_count'
    GH_TOKEN="$BBW" gh api repos/petry-projects/.github-private/actions/variables --jq '.total_count'
    unset BBW

    Pass: the variable list (and the create, PATCH, DELETE of BUDGET_BREAKER_PROBE run with GH_TOKEN="$BBW") succeed; the secrets list and the repository variable list are refused (403 or 404). The token's own settings page is the authority for "nothing else": it should list organisation Variables read and write, and no repository permission.

    Item 12: the drill needs a test organisation

    The three variable names are fixed in scripts/lib/budget-breaker-writer.sh and cannot be overridden from the environment, which is deliberate: nothing can point the writer at another name by accident. So a drill "with throwaway variables" is not possible without a code change. The organisation can be overridden (BUDGET_BREAKER_ORG), so the drill has to run scripts/budget_breaker.sh locally against a throwaway organisation, with a credential for that organisation only. I recommend that route over adding a test-only override of the names. I can write the step-by-step procedure for it once a test organisation exists; I have not, because the commands depend on its name and on how quota markers are seeded there.

    Still open after this comment

    6, 7, 9 and 12 are the maintainer's. 10 waits for a real dev-lead rate limit. 8, 11 and 13 are recorded as observed with the limits stated; say if a real response longer than 30 variables or an observed overlap is required before arming.

  4. don-petry commented on Oct 12, 2026

    @don-petry
    CollaboratorAuthor

    Item 12 (the hand-pause drill): replaced, by maintainer decision, 2026-10-11

    There is no throwaway organisation to run the drill in, and the variable names are fixed in the writer on purpose. Instead of creating an organisation or adding a way to rename the variables, the two races are closed by procedure and the one assumption behind it becomes a live check. Documentation change: #2314.

    The procedure. A pause set by hand is three calls, in this order: delete AGENTS_PAUSE_SOURCE, set AGENTS_PAUSED=true, delete AGENTS_PAUSE_SOURCE again. The first delete removes a leftover source (the second race); the last covers a trip that had already passed its final check (the first race). A two-call version (set, then delete) is not enough: a breaker run landing between the two calls on a leftover source can lift the pause.

    The new live check, replacing item 12. A read of the variable list must see a write made just before it. Create the throwaway variable with the item 6 command above, run this, then delete it with the last command of that block:

    for i in $(seq 1 20); do gh api -X PATCH orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE -f value="v$i"; got=$(gh api "orgs/petry-projects/actions/variables?per_page=30" --jq '.variables[]|select(.name=="BUDGET_BREAKER_PROBE").value'); [ "$got" = "v$i" ] || echo "STALE READ at $i: got $got"; done; echo finished

    Pass: finished with no STALE READ line. A stale read means the breaker's guard can miss a pause set a moment earlier, and a real drill is needed after all.

    Also found while writing it: gh variable set --org defaults to visibility private and sends it on updates too. Every repository here is public, so a pause set that way would reach none of them. The procedure uses value-only API calls, and the existing fleet variable AI_ENGINES has visibility all.

    Remaining for the maintainer on this issue: checks 6, 7 and 9, and the loop above. Check 10 waits for a real dev-lead rate limit.

  5. don-petry commented on Oct 14, 2026

    @don-petry
    CollaboratorAuthor

    Update to the live check that replaces item 12 (use this loop, not the one above)

    Review of #2314 found the earlier loop tested updates only, while the hand-pause guarantee also depends on a deleted variable being gone from the next read. This one creates, updates and deletes. It needs no setup and leaves nothing behind:

    for i in $(seq 1 20); do gh api -X POST orgs/petry-projects/actions/variables -f name=BUDGET_BREAKER_PROBE -f value="c$i" -f visibility=private >/dev/null; got=$(gh api "orgs/petry-projects/actions/variables?per_page=30" --jq '.variables[]|select(.name=="BUDGET_BREAKER_PROBE").value'); [ "$got" = "c$i" ] || echo "STALE after create $i: '$got'"; gh api -X PATCH orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE -f value="u$i"; got=$(gh api "orgs/petry-projects/actions/variables?per_page=30" --jq '.variables[]|select(.name=="BUDGET_BREAKER_PROBE").value'); [ "$got" = "u$i" ] || echo "STALE after update $i: '$got'"; gh api -X DELETE orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE; got=$(gh api "orgs/petry-projects/actions/variables?per_page=30" --jq '.variables[]|select(.name=="BUDGET_BREAKER_PROBE").value'); [ -z "$got" ] || echo "STALE after delete $i: '$got'"; done; echo finished

    Pass: finished with no STALE line. If the loop is interrupted, remove the variable with gh api -X DELETE orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE. Once BUDGET_BREAKER_PAT exists, a second pass with the reads made by that credential is worth doing, since it is the breaker's reader.

    The procedure itself also changed in review: it now ends with a check a minute later (repeat if the fleet is not paused), because a resume the breaker has already decided on can undo a hand pause and no ordering of calls prevents that; and the pause call sets visibility=all every time.

  6. don-petry commented on Oct 14, 2026

    @don-petry
    CollaboratorAuthor

    Correction to the loop posted above, from review of #2314: the reads should paginate the way the breaker reads (gh api --paginate on ?per_page=30), so a page boundary cannot hide the variable. Use this one:

    for i in $(seq 1 20); do gh api -X POST orgs/petry-projects/actions/variables -f name=BUDGET_BREAKER_PROBE -f value="c$i" -f visibility=private >/dev/null; got=$(gh api --paginate "orgs/petry-projects/actions/variables?per_page=30" --jq '.variables[]|select(.name=="BUDGET_BREAKER_PROBE").value'); [ "$got" = "c$i" ] || echo "STALE after create $i: '$got'"; gh api -X PATCH orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE -f value="u$i"; got=$(gh api --paginate "orgs/petry-projects/actions/variables?per_page=30" --jq '.variables[]|select(.name=="BUDGET_BREAKER_PROBE").value'); [ "$got" = "u$i" ] || echo "STALE after update $i: '$got'"; gh api -X DELETE orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE; got=$(gh api --paginate "orgs/petry-projects/actions/variables?per_page=30" --jq '.variables[]|select(.name=="BUDGET_BREAKER_PROBE").value'); [ -z "$got" ] || echo "STALE after delete $i: '$got'"; done; echo finished

    Pass: finished with no STALE line. If interrupted: gh api -X DELETE orgs/petry-projects/actions/variables/BUDGET_BREAKER_PROBE.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions