Skip to content
87 changes: 86 additions & 1 deletion scripts/agent-rate-limit-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@
# dispatch step on it. DRY_RUN / DEV_LEAD_DRY_RUN log every intended decision and
# mutate nothing (AC #7).
#
# Org-wide token-budget breaker (#1155): in addition to the per-agent-type checks
# above, the orchestrator also consults the org-wide Claude token-budget breaker
# (the `session` 5-hour and `weekly_all` 7-day account windows; `weekly_scoped` is
# never consulted — ADR §2.5). A `defer` from EITHER half defers the dispatch. The
# whole consultation is behind AGENT_TOKEN_BUDGET_ENABLED: unset (the default) ⇒
# skipped entirely, so the output is byte-identical to before this change; it is
# inert until a maintainer arms the flag AND wires the private telemetry seam
# (petry-projects/.github-private#1565). Even armed, the weekly glide half stays
# inert until org_wide.token_budget.limits.weekly_all.enabled is flipped in config.
#
# Usage:
# agent-rate-limit-gate.sh <agent_type> [options]
# --mode enforce|log-only default: log-only (only initiative-driver enforces)
Expand All @@ -48,7 +58,10 @@
#
# Env: SOURCE_NOW (epoch override, testability), DRY_RUN / DEV_LEAD_DRY_RUN,
# AGENT_RATE_LIMITS_CONFIG (library override), ARGATE_LIB_ONLY (source
# without running main — for unit tests).
# without running main — for unit tests), AGENT_TOKEN_BUDGET_ENABLED (arm the
# org-wide token-budget consultation; unset ⇒ skipped entirely),
# AGENT_TOKEN_BUDGET_TELEMETRY_CMD / AGENT_TOKEN_BUDGET_TELEMETRY_FILE (the
# private telemetry adapter seam the library reads the usage envelope from).

# Source the co-located pure gate library.
_ARGATE_HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd)"
Expand Down Expand Up @@ -241,6 +254,62 @@ argate_escalate() {
fi
}

# ---------------------------------------------------------------------------
# argate_token_budget — consult the org-wide token-budget breaker across the two
# pause-worthy account-wide windows and echo `allow` or `defer` on stdout (#1155).
#
# The telemetry envelope is fetched ONCE (through the library's private adapter
# seam) and fed to BOTH library gates: arl_token_budget_gate for the 5-hour
# `session` window and arl_token_weekly_glide_gate for the 7-day `weekly_all`
# window. `weekly_scoped` is deliberately NOT consulted — per-model exhaustion is
# the engine's model-fallback chain, never a fleet pause (ADR §2.5, AC #4).
#
# Observability (AC #5): every window read, its percent, and its threshold are
# logged, and — critically — whether telemetry was ACTUALLY obtained (a 200
# envelope) is logged distinctly, so a fail-open on absent/degraded telemetry is
# visibly different from a genuine under-threshold allow. On a trip the window's
# human-clearable escalation marker is logged too.
#
# Guard-only: reads telemetry, never mutates. Returns 0 always (the decision
# rides on stdout).
# ---------------------------------------------------------------------------
argate_token_budget() {
local envelope status body decision="allow"
envelope="$(arl_token_fetch_envelope)"
status="$(arl_sanitize_int "$(jq -r '.status? // 0' <<<"$envelope" 2>/dev/null || printf '0')")"

if [ "$status" -eq 200 ]; then
argate_log "token-budget: telemetry OBTAINED (status=200) — evaluating pause-worthy account windows (session, weekly_all)"
else
argate_log "token-budget: telemetry NOT obtained (status=${status}) — the breaker fails open; any allow below is DEGRADED, not a genuine under-threshold allow"
fi
body="$(jq -c '.body? // {}' <<<"$envelope" 2>/dev/null || printf '{}')"

# session (5-hour, static config threshold).
local s_percent s_threshold s_decision
s_percent="$(arl_token_extract_percent "$body" session)"
s_threshold="$(arl_token_pause_threshold session)"
argate_log "token-budget[session]: percent=${s_percent:-<none>} threshold=${s_threshold:-<none>}%"
s_decision="$(arl_token_budget_gate session "$envelope")" || true
if [ "$s_decision" = "decision=defer" ]; then
decision="defer"
argate_log "token-budget[session] TRIP — escalation marker: $(arl_token_breaker_marker session)"
Comment thread
don-petry marked this conversation as resolved.
Resolved
fi

# weekly_all (7-day, time-varying glide-path threshold). Inert until
# weekly_all.enabled is set in config, even when the env flag is armed.
local w_percent w_decision
w_percent="$(arl_token_extract_percent "$body" weekly_all)"
argate_log "token-budget[weekly_all]: percent=${w_percent:-<none>} threshold=glide-path (config-enabled=$(arl_token_glide_enabled))"
w_decision="$(arl_token_weekly_glide_gate "$envelope")" || true
if [ "$w_decision" = "decision=defer" ]; then
decision="defer"
argate_log "token-budget[weekly_all] TRIP — escalation marker: $(arl_token_breaker_marker weekly_all)"
fi

printf '%s' "$decision"
}

# ---------------------------------------------------------------------------
# argate_emit <decision> — print `decision=<decision>` to stdout and, when
# running under GitHub Actions, append it to $GITHUB_OUTPUT so the caller can
Expand Down Expand Up @@ -343,6 +412,22 @@ argate_gate() {
[ "$admission" != "decision=allow" ] && decision="defer"
fi

# Org-wide token-budget breaker (#1155). INERT unless AGENT_TOKEN_BUDGET_ENABLED
# is set: unset ⇒ this block is skipped entirely, so the output stays
# byte-identical to the pre-#1155 per-agent-only behaviour (AC #2/#6). When
# armed, a `defer` from the token-budget half defers dispatch in addition to the
# per-agent-type checks above — the account-wide budget is consulted regardless
# of whether a per-agent limit already deferred, so the observability log always
# records the budget state (AC #1/#5).
if [ "${AGENT_TOKEN_BUDGET_ENABLED:-false}" = "true" ]; then
local token_decision
token_decision="$(argate_token_budget)"
if [ "$token_decision" = "defer" ]; then
argate_log "token-budget breaker deferred dispatch for '${agent_type}' (org-wide account budget over threshold)"
decision="defer"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
fi
fi

# DRY_RUN: log the intended decision, mutate nothing, do not block (AC #7).
if arl_is_dry_run; then
argate_log "DRY_RUN — computed decision=${decision} for '${agent_type}'; emitting allow, no side effects"
Expand Down
2 changes: 1 addition & 1 deletion standards/agent-rate-limits.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"status": "signed-off",
"_note": "Human sign-off 2026-09-01 (epic #636 gate; maintainer sign-off comment on #636). This is the machine-readable single source of truth for per-agent-type rate limits, circuit breakers, and the org-wide Claude token-budget breaker. Decision record: docs/initiatives/agent-rate-limits-adr.md (#637). The per-agent-type limits and consecutive-failure breakers are now CONSUMED at dispatch time by scripts/agent-rate-limit-gate.sh (Phase 4, #640): initiative-driver ENFORCES, every other agent type runs log-only. org_wide.token_budget stays out of the live path (AGENT_TOKEN_BUDGET_ENABLED is unset) and org_wide.limits.weekly_all.enabled stays false — arming the weekly glide-path breaker is a separate, deliberately-out-of-scope change (#994). Consumers read values from this single file, never hardcoded.",
"_note": "Human sign-off 2026-09-01 (epic #636 gate; maintainer sign-off comment on #636). This is the machine-readable single source of truth for per-agent-type rate limits, circuit breakers, and the org-wide Claude token-budget breaker. Decision record: docs/initiatives/agent-rate-limits-adr.md (#637). The per-agent-type limits and consecutive-failure breakers are now CONSUMED at dispatch time by scripts/agent-rate-limit-gate.sh (Phase 4, #640): initiative-driver ENFORCES, every other agent type runs log-only. org_wide.token_budget is now wired into that same dispatch path but DISARMED (#1155): scripts/agent-rate-limit-gate.sh consults the session + weekly_all windows, yet the consultation is gated on AGENT_TOKEN_BUDGET_ENABLED which stays unset, and org_wide.token_budget.limits.weekly_all.enabled stays false — so the breaker is inert (byte-identical to per-agent-only behaviour). Arming it (setting the flag with the private telemetry seam, and flipping weekly_all.enabled for the glide-path half) is a separate, deliberately-out-of-scope change (#994). Consumers read values from this single file, never hardcoded.",
"_schema_version": 1,
"agent_types": {
"dev-lead": {
Expand Down
22 changes: 17 additions & 5 deletions standards/agent-rate-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,23 @@ the gate, mirroring the PR-Limits sign-off exactly (ADR §7,
Phase-4 orchestrator
[`scripts/agent-rate-limit-gate.sh`](../scripts/agent-rate-limit-gate.sh)
([#640](https://github.com/petry-projects/.github/issues/640)), which wraps the
pure gate library. The org-wide token-budget breaker (§4) stays out of the live
path — `AGENT_TOKEN_BUDGET_ENABLED` is unset and
`org_wide.token_budget.limits.weekly_all.enabled` stays `false` — so arming the
weekly glide-path breaker remains a separate, deliberately out-of-scope change
([#994](https://github.com/petry-projects/.github/issues/994)).
pure gate library.

The org-wide token-budget breaker (§4) is now **wired into that same orchestrator's
decision path but disarmed** ([#1155](https://github.com/petry-projects/.github/issues/1155)):
`scripts/agent-rate-limit-gate.sh` consults both account-wide windows (`session`
and `weekly_all`; `weekly_scoped` is never consulted, ADR §2.5) alongside the
per-agent-type checks, and a `defer` from the token-budget half defers dispatch —
but the whole consultation is gated on `AGENT_TOKEN_BUDGET_ENABLED`, which stays
**unset**, so the breaker is inert and the gate's output is byte-identical to the
per-agent-only behaviour. It is therefore **wired-but-disarmed**, not
signed-off-and-armed: arming it is a deliberate maintainer change that must (a)
set `AGENT_TOKEN_BUDGET_ENABLED` with the private telemetry seam wired
([petry-projects/.github-private#1565](https://github.com/petry-projects/.github/issues/1565)),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
and (b) flip `org_wide.token_budget.limits.weekly_all.enabled` from `false` to arm
the weekly glide-path half — each a separate, deliberately out-of-scope change
([#994](https://github.com/petry-projects/.github/issues/994)). See §4.2 for the
staged rollout.

### 3.1 Canary scope — enforcing vs log-only, and the promotion path (AC #5)

Expand Down
144 changes: 144 additions & 0 deletions tests/test_agent_rate_limit_gate.bats
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,55 @@ STUB
}' >"$AGENT_RATE_LIMITS_CONFIG"
}

# A config carrying the org_wide.token_budget block (session threshold +
# weekly_all glide keys + the non-pause-worthy weekly_scoped) plus one agent
# type, for the token-budget wiring tests (#1155). weekly_all.enabled is left
# unset (defaults false) so the glide breaker stays inert until a maintainer
# arms it in config — arming it is deliberately out of scope.
write_token_gate_config() {
local agent="$1" threshold="${2:-90}"
export AGENT_RATE_LIMITS_CONFIG="$TMP/agent-rate-limits.json"
jq -n --arg agent "$agent" --argjson threshold "$threshold" '{
status: "signed-off", _schema_version: 1,
agent_types: { ($agent): {
max_concurrent_runs: 3, max_runtime_minutes: 30,
cooldown_minutes: 0, daily_run_budget: 50,
circuit_breaker: { consecutive_failure_threshold: 3, backoff_minutes: 30 }
} },
org_wide: { token_budget: {
claude_priority: true,
limits: {
session: { kind: "session", window_hours: 5, pause_worthy: true, pause_threshold_pct: $threshold },
weekly_all: { kind: "weekly_all", window_days: 7, pause_worthy: true, reserve_pct_per_day: 2, floor_pct: 86, ceiling_pct: 100 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
weekly_scoped: { kind: "weekly_scoped", pause_worthy: false }
}
} },
exempt_actors: ["dependabot[bot]", "@petry-projects/org-leads"],
exempt_labels: ["security"]
}' >"$AGENT_RATE_LIMITS_CONFIG"
}

# Point the telemetry adapter seam at a fixture file holding the normalized
# envelope JSON the private poller would emit.
write_telemetry() {
export AGENT_TOKEN_BUDGET_TELEMETRY_FILE="$TMP/telemetry.json"
printf '%s' "$1" >"$AGENT_TOKEN_BUDGET_TELEMETRY_FILE"
}

# A 200 envelope whose body carries a limits[] array with the given session /
# weekly_all percentages (weekly_scoped pinned at 100 to prove it is ignored).
envelope_limits() {
local session_pct="$1" weekly_pct="${2:-40}"
jq -nc --argjson s "$session_pct" --argjson w "$weekly_pct" '{
status: 200,
body: { limits: [
{ kind: "session", percent: $s, resets_at: "2026-08-31T12:00:00Z", is_active: true },
{ kind: "weekly_all", percent: $w, resets_at: "2026-09-02T15:59:59Z", is_active: true },
{ kind: "weekly_scoped", percent: 100, resets_at: "2026-09-02T15:59:59Z", is_active: true }
] }
}'
}

# Build a run-history JSON array. Each argument is "conclusion@ISO8601"; a
# status of in_progress/queued is expressed as "in_progress@ISO" (empty
# conclusion). Runs are emitted as gh's `run list --json` shape.
Expand Down Expand Up @@ -376,3 +425,98 @@ refute_mutated() {
[[ "$output" == *"decision=allow"* ]]
refute_mutated
}

# ==========================================================================
# Org-wide token-budget breaker wiring (#1155)
#
# The orchestrator must consult the org-wide token-budget breaker (session +
# weekly_all) in its decision path, in addition to the per-agent-type checks —
# but only when AGENT_TOKEN_BUDGET_ENABLED is set. A defer from the token-budget
# half defers dispatch; with the flag unset the behaviour is byte-identical to
# before this change. `weekly_scoped` is never consulted (ADR §2.5).
# ==========================================================================

@test "token-budget: armed + over-threshold session envelope defers under enforce (AC #6)" {
write_token_gate_config "initiative-driver" 90
write_telemetry "$(envelope_limits 95 40)"
export GH_RUNS_JSON; GH_RUNS_JSON="[]"
# Admission is clean (no run history, cooldown 0), so the ONLY reason to defer
# is the org-wide token budget being over threshold.
run bash -c "AGENT_TOKEN_BUDGET_ENABLED=true SOURCE_NOW=1893456000 bash '$GATE' initiative-driver --mode enforce --actor donpetry-bot"
[ "$status" -eq 0 ]
[[ "$output" == *"decision=defer"* ]]
}

@test "token-budget: with the flag unset an over-threshold envelope has no effect (AC #2/#6 inert)" {
write_token_gate_config "initiative-driver" 90
write_telemetry "$(envelope_limits 95 40)"
export GH_RUNS_JSON; GH_RUNS_JSON="[]"
run bash -c "SOURCE_NOW=1893456000 bash '$GATE' initiative-driver --mode enforce --actor donpetry-bot"
[ "$status" -eq 0 ]
[[ "$output" == *"decision=allow"* ]]
# No token-budget code path runs at all when the flag is unset.
[[ "$output" != *"token-budget"* ]]
}

@test "token-budget: with the flag unset output is byte-identical whether or not telemetry is present (AC #6)" {
write_token_gate_config "initiative-driver" 90
export GH_RUNS_JSON; GH_RUNS_JSON="[]"
local with_tel without_tel
write_telemetry "$(envelope_limits 95 40)"
run bash -c "SOURCE_NOW=1893456000 bash '$GATE' initiative-driver --mode enforce --actor donpetry-bot"
[ "$status" -eq 0 ]
with_tel="$output"
unset AGENT_TOKEN_BUDGET_TELEMETRY_FILE
rm -f "$TMP/telemetry.json"
run bash -c "SOURCE_NOW=1893456000 bash '$GATE' initiative-driver --mode enforce --actor donpetry-bot"
[ "$status" -eq 0 ]
without_tel="$output"
[ "$with_tel" = "$without_tel" ]
Comment thread
don-petry marked this conversation as resolved.
Resolved
}

@test "token-budget: armed + over-threshold under log-only emits allow but logs the would-be defer and percent (AC #3)" {
write_token_gate_config "feature-ideation" 90
write_telemetry "$(envelope_limits 95 40)"
export GH_RUNS_JSON; GH_RUNS_JSON="[]"
run bash -c "AGENT_TOKEN_BUDGET_ENABLED=true SOURCE_NOW=1893456000 bash '$GATE' feature-ideation --mode log-only --actor donpetry-bot"
[ "$status" -eq 0 ]
[[ "$output" == *"decision=allow"* ]]
# Observability: the computed defer and the session percent are logged even
# though nothing is acted on (canary discipline).
[[ "$output" == *"defer"* ]]
[[ "$output" == *"95"* ]]
refute_mutated
}

@test "token-budget: weekly_scoped at 100% never defers when session is under threshold (scope guard, AC #4)" {
write_token_gate_config "initiative-driver" 90
# session 50 (under), weekly_all 40 (under + glide disarmed), weekly_scoped 100.
write_telemetry "$(envelope_limits 50 40)"
export GH_RUNS_JSON; GH_RUNS_JSON="[]"
run bash -c "AGENT_TOKEN_BUDGET_ENABLED=true SOURCE_NOW=1893456000 bash '$GATE' initiative-driver --mode enforce --actor donpetry-bot"
[ "$status" -eq 0 ]
[[ "$output" == *"decision=allow"* ]]
}

@test "token-budget: absent telemetry allows but logs a DEGRADED fail-open distinct from a healthy allow (AC #5)" {
write_token_gate_config "initiative-driver" 90
export GH_RUNS_JSON; GH_RUNS_JSON="[]"
run bash -c "unset AGENT_TOKEN_BUDGET_TELEMETRY_FILE AGENT_TOKEN_BUDGET_TELEMETRY_CMD; AGENT_TOKEN_BUDGET_ENABLED=true SOURCE_NOW=1893456000 bash '$GATE' initiative-driver --mode enforce --actor donpetry-bot"
[ "$status" -eq 0 ]
[[ "$output" == *"decision=allow"* ]]
# The degraded fail-open must be visibly distinct from a genuine allow (AC #5).
[[ "$output" == *"NOT obtained"* ]]
}

@test "token-budget: obtained under-threshold telemetry logs a genuine (non-degraded) allow (AC #5)" {
write_token_gate_config "initiative-driver" 90
write_telemetry "$(envelope_limits 10 10)"
export GH_RUNS_JSON; GH_RUNS_JSON="[]"
run bash -c "AGENT_TOKEN_BUDGET_ENABLED=true SOURCE_NOW=1893456000 bash '$GATE' initiative-driver --mode enforce --actor donpetry-bot"
[ "$status" -eq 0 ]
[[ "$output" == *"decision=allow"* ]]
[[ "$output" == *"OBTAINED"* ]]
# Both pause-worthy windows are read and their percents surfaced (AC #5).
[[ "$output" == *"session"* ]]
[[ "$output" == *"weekly_all"* ]]
}
Loading