## Description
`parse_copilot_quota` reads each category's remaining count like this
(`headroom/subscription/copilot_quota.py`):
```python
remaining = raw.get("remaining") or raw.get("quota_remaining")
```
When a Copilot category is fully consumed, the `/copilot_internal/user`
API sends
`remaining: 0`. The `or` chain treats that legitimate `0` as falsy and —
since the real
per-category payload emits `remaining`, not the `quota_remaining` alias
— collapses it to
`None`:
```python
{"entitlement": 300, "remaining": 0} # fully spent
# raw.get("remaining") -> 0 (falsy) -> raw.get("quota_remaining") -> None -> remaining = None
```
With `remaining = None`, the derived properties break:
- `CopilotQuotaCategory.used` (needs `remaining is not None`) → `None`
instead of `entitlement`
- `used_percent`, when the API also omits `percent_remaining` for that
category → `None`
`to_dict` then emits `remaining: None, used: None, used_percent: None`,
so the dashboard
renders a **100%-exhausted** quota as `used: -` and a **0% green** gauge
— telling the user
they have full quota left when they have none.
Only the `remaining` field has this falsy-zero bug;
`entitlement`/`percent_remaining` are
already parsed with a plain `.get()`, and `overage_count`'s `or 0` is
benign because `0` is
its intended default.
Closes: no issue filed — found while auditing the subscription/quota
parsing.
## Fix
Use an explicit `is None` check, matching how the sibling fields are
parsed:
```python
remaining = raw.get("remaining")
if remaining is None:
remaining = raw.get("quota_remaining")
```
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/subscription/copilot_quota.py`: parse `remaining` with an
explicit `is None` check so a legitimate `0` survives (alias fallback
only when the key is truly absent).
- `tests/test_copilot_quota.py`: add
`test_fully_exhausted_remaining_zero_is_preserved` (remaining `0` →
`used == entitlement`, `used_percent == 100`).
## Testing
- [x] New regression test added (`tests/test_copilot_quota.py`)
- [x] Linting passes (`ruff check`) and formatting is clean (`ruff
format --check`) — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).
```text
$ uvx ruff@0.15.17 check headroom/subscription/copilot_quota.py tests/test_copilot_quota.py
All checks passed!
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the parse +
`used`/`used_percent` logic with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: ran a fully-exhausted category (`entitlement:
300, remaining: 0`, no alias/percent) through both the old `or`
expression and the new `is None` check, then through the
`used`/`used_percent` property logic.
- Observed result: the old path yields `remaining=None → used=None,
used_percent=None` (the misleading 0%/green); the new path preserves `0`
and reports 100%:
```text
OLD remaining: None used=None used_percent=None
NEW remaining: 0 used=300 used_percent=100.0
-> OLD renders exhausted quota as unknown (0%/green); NEW shows 300/300 = 100%
OK alias fallback + normal values preserved
COPILOT QUOTA ZERO-REMAINING FIX VERIFIED
```
- Not tested: rendering the actual dashboard HTML (needs the running
app). The fix is confined to the parse function and the new test asserts
the parsed `used`/`used_percent`. Full local `pytest` deferred to CI
(OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- One-line falsy-zero fix plus a test; no new dependencies.
- @JerrettDavis tagging you — small one, but it makes the Copilot
dashboard show a spent quota as 100% instead of a green 0%, so worth a
quick look when you have a moment.
Co-authored-by: JD Davis <mxjerrett@gmail.com>
`.gitattributes` declares `*.py text eol=lf` and `*.sh text eol=lf`, but
74 files (73 .py, 1 .sh) are stored in the index with CRLF line endings,
violating that contract. Every macOS/Linux clone reports these files as
"modified" on fresh checkout because git's diff engine sees the stored
bytes don't match the attribute contract, even though the working tree
and index match byte-for-byte.
Running `git add --renormalize .` rewrites each affected blob so the
stored form matches the attribute declaration. No semantic changes —
every affected file's diff is "N insertions, N deletions" with inserts
and deletes being the same lines modulo line endings.
Follow-up commit adds `.git-blame-ignore-revs` so `git blame` / GitHub
blame skip this mechanical commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both SubscriptionTracker._poll_loop and _CopilotQuotaTracker._poll_loop
wrapped their stop-event wait in asyncio.shield() inside wait_for().
When wait_for timed out (every poll_interval_s), it cancelled its own
outer task but the shielded inner Event.wait kept running forever —
one leaked Task per poll interval per tracker.
Across the two default trackers (10s poll each) this leaks ~0.2
tasks/sec in steady state: ~17k/day, ~120k/week. Event-loop
scheduler cost grows linearly with task count, which eventually
starves /livez and new WS accepts on long-lived processes. This
matches the 'aged :8787 proxy degrades over hours/days' symptom
captured in wiki/plans/2026-04-17-codex-proxy-runtime-analysis.md.
Discovered by the /debug/tasks endpoint added in Unit 5 of the
codex-proxy-resilience plan — 60s of idle time on a fixed-version
fork now shows 0 Event.wait tasks vs 310 → 320 growth on the
unpatched build.
Drop the shield wrapper so wait_for can cleanly cancel the inner
wait when its timeout fires. The stop() contract is unaffected:
setting _stop_event still returns the wait normally before the
timeout, triggering the break.
Adds a regression test per tracker that starts the poll loop with
a 50ms interval, lets it run for ~6 cycles, stops it, and asserts
Event.wait task count does not grow beyond baseline + 1 (the one
legitimate in-flight waiter).