Skip to content

feat(quota-plane): quota-gatherer service in the PM stack — 15-minute tick, quota-current contract #1151

Closed

Description

@dezren39

Priority P0 — order 1. Horizon: Week. Design: quota-plane-design-2026-10-26.md (usage-billing-hub goal files), Stage 2. Parent context: epic #1047. (§1)

Build the quota-gatherer as a PM-stack process: 15-minute tick; one adapter per provider with isolated failures; writes (a) the append-only snapshot store (existing) and (b) quota-current — the latest-state contract per pool/service (value, % of limit, resetsAt, measuredAt, source, state). Per-adapter heartbeat + last-error emitted every tick (feeds the self-monitoring issue #1116). check-usage.py snapshots fold in as the local-server adapter. Runs where the fleet-feed collector runs today (sandbox) with the feed carrying its output to the laptop aggregator; placement reviewed if adapter token storage argues for the laptop instead.
Acceptance: two consecutive ticks produce current, correctly-sourced quota-current for every adapter-covered provider; one deliberately broken adapter degrades only its own rows.

Activity

  1. developing-today commented on Oct 27, 2026

    @developing-today
    Contributor
  2. dezren39 commented on Oct 27, 2026

    @dezren39
    MemberAuthor

    REVIEW FINDING (2026-10-26): integration seam for the gatherer. Two options, both real today: (a) shell openchamber quota --json (per-provider or all) from the OpenChamber checkout; (b) import the quota module in-process (ESM, server/lib/quota/index.js exports fetchQuotaForProvider / fetchQuotaForProviderInstances / listConfiguredQuotaProviders).
    Caveat found live: the CLI resolves credentials via the OpenCode server API (default http://127.0.0.1:4096) and fails wholesale with "Transport: fetch failed" when no server is listening (verified 2026-10-26 23:1x CDT — no server on 4096 in the sandbox). The gatherer must either guarantee a server, or use the module's direct credential-store read (readOpenCodeCredentials) so a stopped server cannot zero the whole tick. Prefer (b) with the direct read; keep (a) as the diagnostic surface.

  3. dezren39 commented on Oct 27, 2026

    @dezren39
    MemberAuthor

    DREW RULING (2026-10-27 00:42 CDT, standing): nothing runs in the sandbox — all builds, downloads, test suites, and servers run on the LAPTOP only; the sandbox keeps config/source aligned and does orchestration only. Applied to this issue: the gatherer deploys and ticks ON THE LAPTOP (where the aggregator + snapshot store already live). The in-flight build has been redirected accordingly; sandbox-side timers/services for this system are out of scope.

  4. dezren39 commented on Oct 27, 2026

    @dezren39
    MemberAuthor

    v1 built + deployed — PR #1309 (branch feat/quota-gatherer, head a7471f0f; not merged, for review).

    Deployment host: the LAPTOP (per Drew's 2026-10-27 standing rule — nothing executes on the sandbox). An earlier iteration of this work briefly installed a sandbox timer; it was removed the same night and the sandbox keeps source only. On the laptop the gatherer follows the local PM-stack patterns: systemd user timer quota-gatherer.timer (OnCalendar=*:0/15, Persistent=true, same shape as the Meta exporter timer), units ship in pm-stack/aggregator/systemd/ and are installed by install.sh; linger makes it boot-persistent.

    Design decisions

    • In-process import, direct credential read: the tick imports the OpenChamber quota module from the laptop checkout (~/git/openchamber-fork) and wires its credential source to a read-only projection of OpenCode's own opencode.db credential table (server-identical selection semantics). No OpenCode server needs to be listening — the wholesale transport failure from the review cannot zero a tick.
    • File-export ingest (the feat(quota-plane): Meta adapter Phase 1 — laptop-side quota exporter (subscription-quota API, 15-min file export) #1301 convention, adopted): each tick also reads ~/atlas/meta-quota-export/latest.json → service meta:high-usage; failure-status documents and files >30 min old become error entries, never zeros.
    • Outputs in the deployed aggregator dir: quota-current.json (the latest-state contract; heartbeat embedded) + quota-gatherer-heartbeat.json. Snapshot records go directly into the aggregator's snapshot store (SnapshotStore.append), change-only (a record lands when a window's value moves; first tick: 56 records).
    • Per-provider isolation: 15s cap each; one failure degrades only its rows. Catastrophic failure (module import / credential store) → heartbeat with the error, non-zero exit, previous quota-current untouched.
    • Mapping is pure + unit-tested (src/quota-gather-map.ts, 11 tests, run on the laptop): provider results → quota-current entries (incl. per-model flattening for Claude/Antigravity shapes) → snapshot records under the hub's existing service ids.

    Live proof (laptop): scheduled tick 2026-10-27 01:00:09 CDT fired from the timer — 8 providers, 7 ok in <1s of provider time (cline-pass, codex ×2 rows, deepseek, antigravity with 44 per-model windows, opencode-go, openai.alternate, meta via file export: 5h 1.02% / weekly 8.83% / free credits $148.57). Second consecutive scheduled tick being verified tonight; results in the follow-up comment.

    Known gaps

  5. dezren39 commented on Oct 27, 2026

    @dezren39
    MemberAuthor

    Verification update — live on the laptop, two consecutive scheduled ticks proven (PR #1309 head now 12e06474; CI green: treefmt, Nix flake eval, test run).

    Scheduled ticks of quota-gatherer.timer (all CDT, laptop amd):

    Two fixes landed during verification:

    1. Change-detection keyed on value only. Tick 2 appended 30 records, most of them noise: several providers recompute the reset instant from a duration on every fetch (sub-second drift; idle 5h windows report "now + 5h"), so keying change on value|resetsAt churned. The deployed state file was re-baselined to the value-only stamps; a smoke tick then appended +3 (genuine moves only) and the next scheduled tick (01:45:07) appended +0.
    2. The unit now runs the script from the laptop's repo clone (~/git/code, tracking this branch), with outputs pinned by env to ~/pm-stack/aggregator. Reason, found live: at 01:28 another workstream's package re-deploy replaced the deployed scripts/ wholesale from a main-based tree and the not-yet-merged gatherer script vanished (the 01:30 tick failed Module not found in the journal; quota-current was NOT touched — fail-soft held). Once feat(aggregator): quota gatherer — 15-minute laptop tick (#1151) #1309 merges, deploys carry the script in-package as well and either path serves it.

    Test evidence (run on the laptop, per the execution rule): test/quota-gather-map.test.ts 11/11 pass. Package typecheck shows only the pre-existing grist/seed/roadmap.json artifact error, identical on unmodified main.

    Still open (unchanged from the first comment): aggregator Source pickup of quota-current.json (the /quota visibility hop), Claude credential re-login (#1251), OpenCode Go per-workspace split, CNY balance records (#1052).

  6. added a commit that references this issue on Oct 27, 2026
  7. dezren39 commented on Oct 27, 2026

    @dezren39
    MemberAuthor

    Final visibility hop DONE (2026-10-27): PR #1329 (head 1a96b5e), deployed on the laptop and verified live. Not merged — parent merges after verification.

    What shipped: the aggregator's live quota source now reads quota-current.json from its own data dir (config PM_AGG_QUOTA_CURRENT_FILE, default ./quota-current.json) and overlays it on the quota view via a new pure module (src/quota-current.ts):

    • Fresh (<=20 min) ok entries win for the pool whose id equals the entry serviceId: percent windows replace the dated-read windows; pool state + choices recomputed. A fresh ok credential row beats an error row for the same service (the Codex two-login case).
    • Error / aged entries surface as the pool's state (error/stale + reason) via a per-pool freshness stamp — never zeros, older windows never presented as silently current.
    • /api/quota.json stays additive: pools gain freshness; a top-level gatherer summary (tickAt, ok, per-provider freshness) rides along. /quota cards render a "gatherer · · measured · · " line.
    • Fail-soft: missing/malformed quota-current.json leaves the base view untouched.

    Tests (run on the laptop): new test/quota-current.test.ts 10/10; full aggregator suite 202/202; typecheck clean (exit 0).

    Live verification (2026-10-27 ~07:02Z, against the gatherer tick generatedAt 2026-10-27T07:00:20Z):

    • meta:high-usage — was walled at weekly 100% on the dated read; now healthy with gatherer windows 5-hour 1.36% / weekly 8.94%, freshness ok, source file-export/meta-laptop, measured 07:00:08Z. Meta is on the page from the gatherer.
    • anthropic:claude-pro — state error, reason "Rate limited. Retrying soon.", dated windows retained under the error badge (not zeroed).
    • openai:codex — weekly 17% (gatherer) replaces the dated 11%. cline-pass — weekly 29 / monthly 71 replace 26 / 69. deepseek — freshness ok (balance-only read, no invented windows).

    Remaining gaps (unchanged, precisely): OpenCode Go still arrives as one combined gatherer row (serviceId "opencode-go") and Antigravity per-model, so neither overlays its split pools yet — both appear in the payload's gatherer summary with their freshness. Both wait on the quota module splitting those providers (noted in the gatherer map when #1309 landed).
    Deploy note for the next operator: install.sh ends with systemctl --user enable --now, which does NOT restart an already-running aggregator — after running install.sh, systemctl --user restart pm-aggregator.service is required for new code to serve (hit and handled during this deploy; the live verification above is post-restart).

  8. added a commit that references this issue on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions