diff --git a/pm-stack/aggregator/README.md b/pm-stack/aggregator/README.md index 46bcaff4c..c4ba2e9ad 100644 --- a/pm-stack/aggregator/README.md +++ b/pm-stack/aggregator/README.md @@ -600,31 +600,38 @@ clone (`~/git/code`) and writes its outputs under Each tick: -- imports the OpenChamber quota module in-process (from the laptop's - OpenChamber checkout) and fetches every configured provider - per-credential, with credentials read **directly from OpenCode's - credential store** (`opencode.db`, read-only) — no OpenCode server - needs to be listening. One provider failing or exceeding the 15s - cap degrades only its own rows. -- ingests laptop **file exports** in the Meta Phase-1 convention - (#1301): a producer's `latest.json` (today: Meta, - `~/atlas/meta-quota-export/latest.json`) maps onto the same - contract; a failure-status document or a file older than 30 - minutes is an error entry, never zeros. -- writes **`quota-current.json`** — the latest-state contract +- runs the OpenDesign session keeper (Drew's provider browser-auth + auto-renewal rule): the one browser-session credential in the + system is read, slid, or re-captured over CDP before it is used, + with its horizon recorded in `opendesign-renewal.json`. The keeper + maintains a credential; it is not a quota source. +- invokes the ONE quota source - the opencode fork's quota + subcommand, `opencode quota --json` (#6125/#6127) - whose + per-provider quota functions live in the providers that own the + credentials. A second invocation asks explicitly for the + route-disabled live pools (`meta`, `command-code`), which a named + ask always fetches. The gatherer fetches no provider itself and + imports no quota module; a provider failing degrades only its own + rows, and #1268 retention keeps last-known rows for errored runs. +- writes **`quota-current.json`** - the latest-state contract (`QuotaCurrentFile` in `src/model.ts`: per provider/credential, windows carrying value, % of limit, reset, measured-at, source, - state) — and `quota-gatherer-heartbeat.json` (tick time, - per-provider state/duration; the heartbeat also rides inside + state; the file's `source` is `opencode-quota`) - and + `quota-gatherer-heartbeat.json` (tick time, per-provider + state/duration; the heartbeat also rides inside quota-current.json), both in the aggregator state directory beside `fleet-feed.json`. -- appends §4 snapshot records for windows whose value **moved** +- appends S4 snapshot records for windows whose value **moved** since the last appended record, directly to the snapshot store (`SnapshotStore.append` dedupes; change-tracking state in `quota-gatherer-state.json`). Setting `USAGE_SNAPSHOT_OUTBOX_PATH` reroutes records to the writers' outbox instead. +- appends one Supabase `agent.quota_records` row per pool entry to + muse-dev2 (source `opencode-quota`), so the readings land in + Supabase on the same tick as the dashboard file. Fail-soft: a sink + failure is logged and never fails the tick. -Fail-soft: a module-import or credential-store failure writes a +Fail-soft: a subcommand failure or unparseable output writes a heartbeat with the error, exits non-zero, and leaves the previous `quota-current.json` untouched. **Remaining hop:** the aggregator does not read `quota-current.json` yet — `Source` pickup (freshness diff --git a/pm-stack/aggregator/scripts/gather-quota.ts b/pm-stack/aggregator/scripts/gather-quota.ts index 71c824311..69e50df42 100755 --- a/pm-stack/aggregator/scripts/gather-quota.ts +++ b/pm-stack/aggregator/scripts/gather-quota.ts @@ -1,94 +1,53 @@ #!/usr/bin/env bun -// Quota gatherer (Stage 2, #1151): the PM stack's 15-minute quota -// tick. Deployed on the LAPTOP (systemd user timer, units in -// pm-stack/systemd/, installed by install.sh) beside the aggregator -// and the snapshot store. Two input families: +// Quota gatherer (Stage 2, #1151; single-source rewire #6127): the PM +// stack's 15-minute quota tick. Deployed on the LAPTOP (systemd user +// timer, units in pm-stack/systemd/, installed by install.sh) beside +// the aggregator and the snapshot store. // -// 1. The OpenChamber quota module, imported in-process from the -// laptop's OpenChamber checkout, fetching every configured -// provider per credential. Credentials come from ASKING THE -// OPENCODE SERVER (Drew's ruling, 2026-11-03: the quota ask -// terminates at the OpenCode server — never at a standalone -// proxy program and never at a direct database read when the -// server can answer): the module's own openCodeCredentialSource -// against GET /api/credential on the standing fork server -// (service-prod.json under ~/.local/state/opencode/ names its -// URL and password; the password is read at tick time and never -// logged) — the quota CLI's primary path (#2490). The DIRECT -// read of OpenCode's credential store (the `credential` table -// of opencode.db, opened read-only, the module's -// openCodeLocalCredentialSource) remains ONLY as the -// transport-failure fallback, the CLI's exact combined-source -// shape: a stopped server switches the tick's reads to the -// local store instead of zeroing the tick (#1251), and when -// even that fails the tick fails loudly and the previous -// quota-current.json stands, with #1268 retaining last-known -// rows per provider. A server that ANSWERS and refuses is -// authoritative — no fallback. The fallback's projection (rows -// ordered (active, time_created, id), the last row per -// integration selected, unreadable rows surfaced rather than -// silently dropped) is the one every surface gets. -// Environment-variable keys are not store rows and never appear -// here — stored credentials win, which is the order OpenCode -// itself uses. -// One leg does NOT terminate at the server: OpenDesign. Its -// provider reads a managed session credential (a Better Auth -// session cookie + workspace id, in OpenChamber's own quota -// credential store) because the billing usage API it answers -// from accepts only that session — the credential the OpenCode -// server holds for `opendesign` is the AMR inference API key, -// which that API refuses (verified 403 as Bearer and as session -// cookie, 2026-11-03), and the server exposes no quota/usage -// surface at all (its route table has credential/session/etc. -// groups, no quota group). The gap is filed as code#5060. -// 3. The Turso org usage API (#1049): usage + subscription over -// the verified route, token from Bitwarden by name. -// 2. File exports (the Phase-1 convention, #1301): producers the -// module cannot reach write latest.json documents on the laptop; -// the gatherer ingests them in the same tick. A document older -// than 30 minutes means the producer is unreachable — an error -// entry, never zeros. The v1 producer (Meta's dev.meta.ai -// console scrape) was retired 2026-11-03: Drew's ruling is that -// quota is gathered by asking OpenCode — family 5 below is its -// replacement, and the producer list is empty today. -// 4. The native ClinePass token adapter (#1159, src/clinepass- -// adapter.ts on the #1153 framework): fills the ClinePass row -// when the OpenChamber module did not already produce a fresh -// one this tick, and only when CLINEPASS_CREDENTIAL_REF names -// a credential reference on this host. An expired token is -// session-expired / re-login with the last-known row retained -// (#1268) — never a 0% row. -// 5. The Meta module ask (2026-11-03): the provider listing -// (family 1) filters providers whose session door is disabled -// in the opencode config, and `meta` is disabled as a direct -// door — sessions reach the pool through model routes — so the -// listing never names it. The gatherer asks the module for meta -// explicitly (runMetaModule): the read runs through OpenCode's -// own credential machinery, which is what gathering quota by -// asking OpenCode means for a route-only pool. +// THE SINGLE SOURCE (Drew's order, 2026-11-05, #6125): every quota +// reading in this tick comes from ONE producer — the opencode fork's +// quota subcommand, `opencode quota --json`, whose per-provider quota +// functions live in the providers that own the credentials +// (developing-today-forks/opencode#1718). The gatherer invokes the +// subcommand; it never fetches a provider itself. What the tick does +// around that single read: // -// 6. The OpenDesign session keeper (src/opendesign-renewal.ts, +// 1. The OpenDesign session keeper (src/opendesign-renewal.ts, // Drew's 2026-11-05 provider browser-auth auto-renewal rule): -// family 1's OpenDesign leg rides on a Better Auth session in -// OpenChamber's managed credential store — the one credential -// in the tick that is a browser session rather than a token. -// Before the provider runs each tick, the keeper reads the -// stored session (authenticated reads slide its expiry once -// the update interval elapses — verified live 2026-11-05: -// expiresAt re-extended to read-time + 7 days), records the +// OpenDesign's quota function rides on a Better Auth session in +// a managed credential store (the one credential in the system +// that is a browser session rather than a token). Before the +// subcommand runs each tick, the keeper reads the stored +// session (authenticated reads slide its expiry), records the // horizon in opendesign-renewal.json beside quota-current.json, // re-captures the cookie from the standing laptop Helium over // CDP when the stored session fails or its horizon runs low -// (validating the candidate before it is stored), and raises -// an alert in that state file while days remain when renewal -// cannot complete. Fail-soft: a keeper error never fails the -// tick. +// (validating the candidate before it is stored), and raises an +// alert in that state file while days remain when renewal +// cannot complete. The keeper maintains a CREDENTIAL; it is not +// a quota source. Fail-soft: a keeper error never fails the tick. +// (Its credential-store accessors live in the OpenChamber lib +// the store was born in — store plumbing, not a reading path.) +// 2. The subcommand, twice: the configured listing +// (`opencode quota --json`) and the explicit ask +// (`opencode quota --json meta command-code`) — providers whose +// routing doors are disabled in the opencode config but whose +// pools are live are always fetched when named, the same +// discipline the gatherer's out-of-band asks kept before the +// rewire. An explicit-ask result replaces a listing result for +// the same provider + credential. The subcommand exits non-zero +// when a requested provider produced no successful result; that +// is a per-provider failure, not a tick failure — stdout still +// carries the results, and only an unparseable/empty stdout +// fails the tick (loudly; the previous quota-current.json +// stands, #1268). // // Outputs: // - quota-current.json — the latest-state contract (model.ts // QuotaCurrentFile) in the deployed aggregator dir, beside -// fleet-feed.json. (Aggregator pickup of this file is the -// remaining hop; see the README's quota-gatherer section.) +// fleet-feed.json. Its `source` is "opencode-quota" +// (GATHERER_SOURCE): readings are the subcommand's, and the file +// says so. // - quota-gatherer-heartbeat.json — the tick's health record // (also embedded in quota-current.json). // - §4 snapshot records for windows whose value moved since the @@ -96,43 +55,44 @@ // snapshot store (SnapshotStore.append dedupes). Setting // USAGE_SNAPSHOT_OUTBOX_PATH switches the sink to the writers' // outbox JSONL instead (the sandbox collector's drain path). +// - Supabase agent.quota_records rows (muse-dev2), one per pool +// entry per tick, source "opencode-quota" — Drew's rule that all +// quota info lands in Supabase on a regular basis in addition to +// the dashboard. (The previous writer of pool rows, source +// "pm-pool", died 2026-10-29; this sink replaces it.) Fail-soft: +// a sink failure is logged and never fails the tick — +// quota-current.json is already written by then. // -// Fail-soft contract: if the module import fails or the credential -// store cannot be read, the tick writes a heartbeat carrying the -// error and exits non-zero WITHOUT touching quota-current.json. A -// single provider failing or exceeding its cap degrades only its own -// rows; the tick still succeeds. A provider whose run errors keeps -// its last-known ok entries from the previous tick's -// quota-current.json (#1268) — retained verbatim, so their original -// measuredAt makes the hub classify them stale instead of the fresh -// error rows replacing the last good data. +// Fail-soft contract: if the subcommand cannot be run or its output +// cannot be parsed, the tick writes a heartbeat carrying the error +// and exits non-zero WITHOUT touching quota-current.json. A single +// provider failing degrades only its own rows; the tick still +// succeeds. A provider whose run errors keeps its last-known ok +// entries from the previous tick's quota-current.json (#1268) — +// retained verbatim, so their original measuredAt makes the hub +// classify them stale instead of the fresh error rows replacing the +// last good data. // // Usage: bun run scripts/gather-quota.ts -// Env: OPENCHAMBER_LIB_DIR (default: first of ~/git/devtoday- -// openchamber, ~/git/openchamber-fork containing the module), -// OPENCODE_DB_PATH (~/.local/share/opencode/opencode.db), -// OPENCODE_URL (default: the service-prod.json URL, else -// http://127.0.0.1:13420), OPENCODE_AUTH_FILE -// (~/.local/state/opencode/service-prod.json — the standing -// server's URL + password, read at tick time, never logged), -// OPENCODE_SERVER_PASSWORD (overrides the file's password), +// Env: QUOTA_SUBCOMMAND (full command override, e.g. a fork-built +// binary path + args, shell-split on spaces; default: +// "opencode quota --json" / the explicit ask appends provider +// ids), QUOTA_SUBCOMMAND_TIMEOUT_MS (240000 — the whole-exec +// cap; the subcommand self-limits per provider), // QUOTA_CURRENT_PATH, QUOTA_HEARTBEAT_PATH, // QUOTA_GATHERER_STATE_PATH, QUOTA_SNAPSHOTS_DIR (./snapshots), // USAGE_SNAPSHOT_OUTBOX_PATH, -// QUOTA_GATHER_TIMEOUT_MS (15000), -// QUOTA_GATHER_CONCURRENCY (6), TURSO_API_TOKEN (else the -// Bitwarden secret turso.platform-api-token-muse-cli, resolved -// by name via ~/tools/bws-env conventions — BWS_BIN and -// BWS_ACCESS_TOKEN_FILE override its plumbing), -// TURSO_ORG_SLUG (dezren39), TURSO_API_BASE -// (https://api.turso.tech), -// CLINEPASS_CREDENTIAL_REF (bitwarden: or file: — -// a CredentialRef for the native ClinePass adapter (#1159), -// never a credential value; unset = not configured on this -// host, live verification gated on #1155). +// QUOTA_GATHER_CONCURRENCY (retained for interface stability; +// the subcommand owns fetch concurrency now), +// OPENCHAMBER_LIB_DIR (ONLY the OpenDesign keeper's credential +// store accessors resolve from here; no quota reading does), +// SUPABASE_QUOTA_SINK=0 disables the Supabase sink (default on; +// token + project ref resolved by name via the resolveBwsSecret +// plumbing: supabase.muse-dev2.access-token-muse-cli / +// supabase.muse-dev2.project-ref). import { existsSync } from "node:fs"; import { execFile } from "node:child_process"; -import { access, appendFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { access, appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -142,25 +102,12 @@ import { entriesWithPoolSplits, entryFromResult, entryHasMeasuredWindow, - entryFromFileExport, - entryFromTurso, lastKnownEntries, - mapWithConcurrency, markRetained, recordsFromEntries, - type FileExportDoc, type QuotaProviderResult, - type TursoSubscriptionDoc, - type TursoUsageDoc, } from "../src/quota-gather-map.ts"; import { SnapshotStore, isSnapshotRecord, snapshotKey } from "../src/snapshots.ts"; -import { - CLINEPASS_PROVIDER_ID, - clinePassErrorEntry, - credentialRefFromConfig, - gatherClinePass, -} from "../src/clinepass-adapter.ts"; -import type { CredentialRef } from "../src/token-adapter.ts"; import { safeProviderReason } from "../src/credential-lifecycle.ts"; import { renewOpenDesignSession, type OpenDesignCredentialLike } from "../src/opendesign-renewal.ts"; @@ -168,6 +115,10 @@ const PKG = join(dirname(fileURLToPath(import.meta.url)), ".."); const HOME = homedir(); const HIDDEN = join(HOME, "workspace/goals/keep-the-four-repos-healthy/hidden_files"); +/** The OpenDesign keeper's credential-store accessors live in the + * OpenChamber lib (the managed store's home). Store plumbing for ONE + * credential the keeper maintains — never a quota reading path: all + * readings come from the subcommand (see the header). */ function resolveLibDir(): string { const candidates = [ process.env.OPENCHAMBER_LIB_DIR, @@ -180,88 +131,29 @@ function resolveLibDir(): string { return candidates[0]; } const libDir = resolveLibDir(); -const dbPath = process.env.OPENCODE_DB_PATH ?? join(HOME, ".local/share/opencode/opencode.db"); const currentPath = process.env.QUOTA_CURRENT_PATH ?? join(PKG, "quota-current.json"); const heartbeatPath = process.env.QUOTA_HEARTBEAT_PATH ?? join(PKG, "quota-gatherer-heartbeat.json"); const statePath = process.env.QUOTA_GATHERER_STATE_PATH ?? join(PKG, "quota-gatherer-state.json"); const snapshotsDir = process.env.QUOTA_SNAPSHOTS_DIR ?? join(PKG, "snapshots"); const outboxPath = process.env.USAGE_SNAPSHOT_OUTBOX_PATH ?? null; -const timeoutMs = Number(process.env.QUOTA_GATHER_TIMEOUT_MS ?? 15_000); -const gatherConcurrency = Number(process.env.QUOTA_GATHER_CONCURRENCY ?? 6); +const subcommandTimeoutMs = Number(process.env.QUOTA_SUBCOMMAND_TIMEOUT_MS ?? 240_000); const OBSERVED_BY = "quota-gatherer"; -/** - * File-export producers ingested each tick (the #1301 convention). - * The v1 producer — Meta, ~/atlas/meta-quota-export/latest.json, a - * dev.meta.ai console scrape — was retired 2026-11-03: quota is - * gathered by asking OpenCode (Drew's ruling), and the module's own - * Meta provider replaced the scrape (see runMetaModule). The list - * stays, empty, for the next producer the module cannot reach; - * entryFromFileExport keeps the convention's mapping tested. - */ -const FILE_EXPORTS: { key: string; path: string; staleMs: number }[] = []; - -interface QuotaModule { - listConfiguredQuotaProviders: (opts?: { includeDisabled?: boolean }) => Promise; - fetchQuotaForProviderInstances: (providerId: string) => Promise; -} -interface AuthModule { - configureOpenCodeCredentials: (source: unknown) => void; - openCodeCredentialSource: (opts: { - buildOpenCodeUrl: (requestPath?: string) => string; - getOpenCodeAuthHeaders: () => Record; - }) => CredentialSourceLike; - // The shared direct credential-store read from lib/opencode/auth.js - // (#2490 phase 3): same projection and failure semantics as the CLI - // fallback — it throws when the store is missing or holds rows but - // none are readable, and that throw reaches main()'s failTick via - // listConfiguredQuotaProviders, so a bad store fails the tick loudly - // instead of producing an empty "not configured" tick. It has no - // listEnvironmentKeys: stored credentials are the whole story for - // this reader, as before. Since 2026-11-03 it is wired only BEHIND - // the server source above (transport-failure fallback), never as - // the primary read. - openCodeLocalCredentialSource: (opts: { dbPath: string }) => CredentialSourceLike; -} - -interface CredentialSourceLike { - list: () => Promise; - create?: (input: unknown) => Promise; - remove?: (credentialID: string) => Promise; - listEnvironmentKeys?: () => Promise>; - usesLocalStore?: () => boolean; - getLaunchEnvironment?: () => unknown; -} - -interface ClaudeCredentialSource { - loadClaudeCredential: () => Promise<{ - expiresAt?: number | null; - source?: "keychain" | "credentials-file" | "opencode-auth" | "env"; - } | null>; -} - -/** Read only Claude expiry metadata; credential values stay local and are discarded. */ -async function claudeExpiryMetadata(): Promise<{ - expiresAt?: string; - source?: "claude-code" | "opencode" | "environment"; -} | null> { - try { - const auth = (await import(join(libDir, "quota/providers/claude/auth.js"))) as unknown as ClaudeCredentialSource; - const credential = await auth.loadClaudeCredential(); - if (!credential) return null; - const metadata: { expiresAt?: string; source?: "claude-code" | "opencode" | "environment" } = {}; - if (typeof credential.expiresAt === "number" && Number.isFinite(credential.expiresAt)) { - const expiry = new Date(credential.expiresAt); - if (Number.isFinite(expiry.getTime())) metadata.expiresAt = expiry.toISOString(); - } - if (credential.source === "keychain" || credential.source === "credentials-file") metadata.source = "claude-code"; - else if (credential.source === "opencode-auth") metadata.source = "opencode"; - else if (credential.source === "env") metadata.source = "environment"; - return metadata; - } catch { - return null; - } -} +/** Providers always asked explicitly, by name, every tick: their + * routing doors are disabled in the opencode config (definitions + * kept, flipped off), so a listing can omit them even though their + * credentials and plans are live pools. A named ask always fetches. + * (Meta: route-only pool, #6079. Command Code: disabled door, live + * plan — its read is the PLAN read; see keepCommandCodePlanWindows. + * DeepSeek: named ask resolves it but the subcommand's listing + * currently omits it — opencode#1729; remove it here when the + * listing and the named ask agree on the configured set. + * Mistral: its console budget read is CDP-backed and exceeds the + * subcommand's 30s per-provider cap under the full listing's + * concurrency (opencode#1729); the small explicit ask gives it + * room, and its result replaces the listing's timed-out one. + * Remove it here when the cap is raised.) */ +const EXPLICIT_ASK_PROVIDERS = ["meta", "command-code", "deepseek", "mistral"]; async function writeAtomic(path: string, text: string): Promise { await mkdir(dirname(path), { recursive: true }); @@ -287,22 +179,6 @@ async function failTick(tickAt: string, started: number, error: string): Promise return 1; } -async function withTimeout(p: Promise, ms: number, label: string): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); - }); - try { - return await Promise.race([p, timeout]); - } finally { - clearTimeout(timer); - } -} - -/** Bitwarden secret NAME for the Turso platform token (#1049). - * The value is resolved at tick time and never logged or stored. */ -const TURSO_SECRET_NAME = "turso.platform-api-token-muse-cli"; - /** * Resolve one Bitwarden Secrets Manager secret by key, without ever * logging its value (the collect-fleet-feed.ts pattern). Plumbing: @@ -373,82 +249,7 @@ async function resolveBwsSecret(key: string): Promise { } } -/** - * Turso org usage (#1049): usage + subscription reads over the - * verified org route, mapped by entryFromTurso. Any failure (no - * token, HTTP error, timeout) degrades to this run's error entry — - * per-provider fail-soft like every other source; never zeros. - */ -async function runTurso(tickIso: string): Promise { - const t0 = Date.now(); - const done = (entry: QuotaCurrentEntry): ProviderRun => ({ - id: "turso", - state: entry.state === "ok" ? "ok" : "error", - ms: Date.now() - t0, - entries: [entry], - ...(entry.error ? { error: entry.error } : {}), - }); - const token = process.env.TURSO_API_TOKEN?.trim() || (await resolveBwsSecret(TURSO_SECRET_NAME)); - if (!token) { - return done( - entryFromTurso(null, null, { - measuredAt: tickIso, - error: `turso token unavailable (Bitwarden secret ${TURSO_SECRET_NAME})`, - }), - ); - } - const base = process.env.TURSO_API_BASE ?? "https://api.turso.tech"; - const slug = process.env.TURSO_ORG_SLUG ?? "dezren39"; - try { - const get = async (path: string): Promise => { - // Fetch AND body parse inside the timeout, with an abort - // signal: a body that stalls after prompt headers must not - // hang the tick (Codex review, PR #2366). - return withTimeout( - (async () => { - const res = await fetch(`${base}/v1/organizations/${slug}${path}`, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(timeoutMs), - }); - if (!res.ok) throw new Error(`turso ${path} HTTP ${res.status}`); - return res.json(); - })(), - timeoutMs, - `turso ${path}`, - ); - }; - const usage = (await get("/usage")) as TursoUsageDoc; - const subscription = (await get("/subscription")) as TursoSubscriptionDoc; - return done(entryFromTurso(usage, subscription, { measuredAt: tickIso })); - } catch (err) { - return done( - entryFromTurso(null, null, { - measuredAt: tickIso, - error: safeProviderReason(err instanceof Error ? err.message : String(err)), - }), - ); - } -} - -/** - * Resolve a ClinePass CredentialRef to its value at tick time - * (#1159): Bitwarden by name (the resolveBwsSecret plumbing), or a - * file whose owner-only mode is enforced, not assumed. The value is - * handed to the adapter's fetch and never logged or stored. - */ -async function resolveClinePassCredentialValue(ref: CredentialRef): Promise { - if (ref.kind === "bitwarden") return resolveBwsSecret(ref.name); - try { - const st = await stat(ref.path); - if ((st.mode & 0o077) !== 0) return null; - const v = (await readFile(ref.path, "utf8")).trim(); - return v || null; - } catch { - return null; - } -} - -/** Managed-credential accessors the OpenChamber quota module exposes (libDir). */ +/** Managed-credential accessors for the OpenDesign keeper (libDir). */ interface OpenDesignCredentialsModule { readManagedCredential: (providerId: string) => OpenDesignCredentialLike | null; writeManagedCredential: (providerId: string, value: OpenDesignCredentialLike) => unknown; @@ -458,10 +259,13 @@ interface OpenDesignProviderModule { } /** - * The OpenDesign session keeper run (family 6 above). Runs before - * the provider listing is fetched so a credential renewed this tick - * is the one the tick's OpenDesign read uses. Fail-soft: any error - * is logged and the tick proceeds with the stored credential as-is. + * The OpenDesign session keeper run. Runs before the subcommand so a + * credential renewed this tick is the one the tick's OpenDesign read + * uses. The validate step performs a quota fetch with the candidate + * credential purely to prove the credential works before it is + * stored — a credential check, not a quota reading (no value from it + * reaches any output). Fail-soft: any error is logged and the tick + * proceeds with the stored credential as-is. */ async function runOpenDesignRenewal(tickAt: string): Promise { if (process.env.OPENDESIGN_RENEWAL_DISABLED === "1") return; @@ -492,130 +296,6 @@ async function runOpenDesignRenewal(tickAt: string): Promise { } } -/** - * The native ClinePass adapter run (#1159). Null when no credential - * reference is configured on this host — the adapter is registered - * but dormant until #1155 names the provenance. A misconfigured - * reference is an error row naming the defect, never a value. - * Previous-value retention is the tick's #1268 pass below, so the - * adapter is collected with no previous here. - */ -async function runClinePass(tickIso: string): Promise { - const t0 = Date.now(); - let ref: CredentialRef | null; - try { - ref = credentialRefFromConfig(process.env.CLINEPASS_CREDENTIAL_REF); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - const entry = clinePassErrorEntry(safeProviderReason(detail), tickIso); - entry.credentialWatch = true; - return { - id: CLINEPASS_PROVIDER_ID, - state: "error", - ms: Date.now() - t0, - entries: [entry], - error: safeProviderReason(detail), - }; - } - if (!ref) return null; - const outcome = await gatherClinePass({ - now: new Date(tickIso), - previous: null, - credentialRef: ref, - resolveCredentialValue: resolveClinePassCredentialValue, - }); - const entry = - outcome.value ?? clinePassErrorEntry(outcome.detail ?? `cline-pass adapter: ${outcome.status}`, tickIso); - entry.credentialWatch = true; - return { - id: CLINEPASS_PROVIDER_ID, - state: outcome.status === "ok" ? "ok" : "error", - ms: Date.now() - t0, - entries: [entry], - ...(outcome.status !== "ok" ? { error: outcome.detail ?? outcome.status } : {}), - }; -} - -/** - * The Meta module ask (2026-11-03, Drew's ask-OpenCode ruling): the - * quota module's provider listing filters out providers whose - * session door is disabled in the opencode config, and `meta` is - * disabled as a direct door (sessions reach the pool through model - * routes), so the listing never names it and the plain tick never - * asked — the board's Meta row went dark even though OpenCode holds - * the pool's credential and the module's Meta provider reads the - * plan's own usage endpoint with it. The gatherer asks explicitly, - * the same out-of-band registration pattern as the native ClinePass - * adapter. No outer timeout wraps the ask: the provider self-times - * out per request (15s), and its account-token fallback is a second - * request — clipping the pair at the generic 15s cap could turn a - * slow success into a false error row. - */ -async function runMetaModule(quota: QuotaModule, tickIso: string): Promise { - const t0 = Date.now(); - try { - // This explicit ask IS the OpenCode-side mechanism, in-process: - // it is the same fetch the OpenChamber CLI's quota subcommand - // runs for a named provider (`openchamber quota meta --json`, - // packages/web/bin/lib/commands-quota.js in the OpenChamber - // fork) — credentials asked from the OpenCode server - // (GET /api/credential, family 1), and an explicit ask is always - // fetched even when the provider is turned off in routing - // (the listing filter governs listing, never an explicit ask). - // The provider then reads Meta's own plan endpoint - // (POST https://api.meta.ai/muse-code/key) with the OpenCode - // `meta` credential, falling back to the Muse CLI account - // token in ~/.config/muse/auth.json when OpenCode holds only - // the minted inference key. OpenCode's internal route_attempt - // history is NOT a substitute: it meters tokens/cost, and Meta's - // plan percentage exists only on Meta's endpoint. - const results = await quota.fetchQuotaForProviderInstances("meta"); - const entries = results.map((r) => entryFromResult(r, tickIso)); - return finalizeRun("meta", entries, t0); - } catch (err) { - return { - id: "meta", - state: "error", - ms: Date.now() - t0, - entries: [], - error: safeProviderReason(err instanceof Error ? err.message : String(err)), - }; - } -} - -/** - * The Command Code explicit ask: the Meta pattern, for the same - * reason. `commandcode` is disabled as a routing door in the - * OpenCode config (definitions kept, flipped off in - * disabled_providers), so the provider listing can drop - * `command-code` even though its credential and plan are live — - * and a provider absent from the listing produced no run at all: - * no error, no #1268 retention, just a silently missing row. - * Drew's read was "working" because an explicit ask always works: - * the module fetches a named provider even when routing turns it - * off (`openchamber quota command-code --json` is the CLI form). - * The read is the PLAN read (see keepCommandCodePlanWindows): - * monthly from the included-credits bucket, 5h/weekly from - * windowLimits — never the purchased/free credit balances. - * No outer timeout, mirroring runMetaModule. - */ -async function runCommandCodeModule(quota: QuotaModule, tickIso: string): Promise { - const t0 = Date.now(); - try { - const results = await quota.fetchQuotaForProviderInstances("command-code"); - const entries = results.map((r) => entryFromResult(r, tickIso)); - return keepCommandCodePlanWindows(finalizeRun("command-code", entries, t0)); - } catch (err) { - return { - id: "command-code", - state: "error", - ms: Date.now() - t0, - entries: [], - error: safeProviderReason(err instanceof Error ? err.message : String(err)), - }; - } -} - interface ProviderRun { id: string; state: "ok" | "error"; @@ -636,7 +316,7 @@ interface ProviderRun { * last good percentages. Downgrade it to an error run carrying the * mapper's no-data reason, flip its entries to error so retention * can replace them with last-known rows, and let the heartbeat say - * why. The entries' failureClass / lastSuccessAt (when the module + * why. The entries' failureClass / lastSuccessAt (when the provider * provides them) ride along in the reason. */ function finalizeRun(id: string, entries: QuotaCurrentEntry[], t0: number): ProviderRun { @@ -663,13 +343,14 @@ function finalizeRun(id: string, entries: QuotaCurrentEntry[], t0: number): Prov } /** Command Code plan windows (Drew's rule, 2026-11-01: Command Code - * reads use PLAN, not BALANCE). The module's provider also returns - * balance windows (purchased_credits / free_credits: label-only, no + * reads use PLAN, not BALANCE). The provider also returns balance + * windows (purchased_credits / free_credits: label-only, no * percentage); those are balances, never quota, so they are stripped * from the entry's windows — the raw payload stays on entry.usage — * and no consumer or snapshot writer can mistake a balance for a * plan reading or read the plan windows as "empty" because the - * first window is a balance. */ + * first window is a balance. This is a mapping rule over the single + * source's output, not a second read. */ const COMMAND_CODE_PLAN_WINDOWS = new Set(["5h", "weekly", "monthly"]); function keepCommandCodePlanWindows(run: ProviderRun): ProviderRun { for (const entry of run.entries) { @@ -678,56 +359,6 @@ function keepCommandCodePlanWindows(run: ProviderRun): ProviderRun { return run; } -async function runProvider(quota: QuotaModule, id: string, tickIso: string): Promise { - const t0 = Date.now(); - try { - const results = await withTimeout(quota.fetchQuotaForProviderInstances(id), timeoutMs, id); - const entries = results.map((r) => entryFromResult(r, tickIso)); - // A provider is ok when at least one credential row answered - // with a measured window; a failing row (e.g. Codex's - // token-sharing login, which OpenAI refuses by design) keeps its - // error on its own entry, and an ok-but-null row degrades the - // run (see finalizeRun). - return finalizeRun(id, entries, t0); - } catch (err) { - return { - id, - state: "error", - ms: Date.now() - t0, - entries: [], - error: safeProviderReason(err instanceof Error ? err.message : String(err)), - }; - } -} - -/** Ingest one file-export document into a quota-current entry. */ -async function runFileExport(fx: (typeof FILE_EXPORTS)[number]): Promise { - const t0 = Date.now(); - let entry: QuotaCurrentEntry; - try { - const st = await stat(fx.path); - let doc: FileExportDoc | null = null; - try { - doc = JSON.parse(await readFile(fx.path, "utf8")) as FileExportDoc; - } catch { - doc = null; - } - entry = entryFromFileExport(doc, { - fileMtimeIso: st.mtime.toISOString(), - stale: Date.now() - st.mtimeMs > fx.staleMs, - }); - } catch { - entry = entryFromFileExport(null, { fileMtimeIso: null, stale: false }); - } - return { - id: fx.key, - state: entry.state === "ok" ? "ok" : "error", - ms: Date.now() - t0, - entries: [entry], - ...(entry.error ? { error: entry.error } : {}), - }; -} - /** The previous tick's quota-current entries, for #1268 last-known * retention. Any read/parse failure means no retention this tick. */ async function readPreviousEntries(): Promise { @@ -754,222 +385,224 @@ async function readState(): Promise> { return new Map(); } -/** - * The standing OpenCode fork server's coordinates (Drew's ruling, - * 2026-11-03: the quota ask terminates at the OpenCode server). The - * service file is the fleet's standing record — the dispatcher's - * OPENCODE_AUTH_FILE convention (pm-stack/dispatch/src/config.ts) — - * naming the server's URL and its password. OPENCODE_URL / - * OPENCODE_SERVER_PASSWORD override it. The password is held in this - * closure only: never logged, never written, never placed in a URL. - * A missing/unreadable file is not fatal: the default loopback URL is - * used with no auth header, and the local-store fallback behind the - * server source covers a server that cannot be reached at all. - */ -async function resolveOpenCodeServer(): Promise<{ baseUrl: string; authHeaders: () => Record }> { - let url = process.env.OPENCODE_URL?.trim() || ""; - let password = process.env.OPENCODE_SERVER_PASSWORD?.trim() || ""; - if (!url || !password) { - try { - const filePath = process.env.OPENCODE_AUTH_FILE ?? join(HOME, ".local", "state", "opencode", "service-prod.json"); - const svc = JSON.parse(await readFile(filePath, "utf8")) as { url?: unknown; password?: unknown }; - if (!url && typeof svc.url === "string" && svc.url.trim()) url = svc.url.trim(); - if (!password && typeof svc.password === "string") password = svc.password; - } catch { - // no service file — the defaults below stand - } - } - const baseUrl = (url || "http://127.0.0.1:13420").replace(/\/+$/, ""); - return { - baseUrl, - authHeaders: () => - password ? { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` } : {}, - }; +/** The subcommand invocation: QUOTA_SUBCOMMAND overrides the whole + * command (interim fork builds, tests); the default is the deployed + * opencode binary's quota subcommand. Explicit provider ids are + * appended as positional args. */ +function subcommandArgv(extraArgs: string[]): string[] { + const override = process.env.QUOTA_SUBCOMMAND?.trim(); + const base = override ? override.split(/\s+/) : ["opencode", "quota", "--json"]; + return [...base, ...extraArgs]; } -const TRANSPORT_ERROR_CODES = new Set([ - "ECONNREFUSED", - "ECONNRESET", - "ECONNABORTED", - "ENOTFOUND", - "EAI_AGAIN", - "ETIMEDOUT", - "EPIPE", -]); +/** Run the subcommand once. A non-zero exit WITH stdout is a + * per-provider failure signal, not an exec failure — the results on + * stdout stand. An exec failure (spawn error, timeout, no stdout) + * rejects. */ +function execSubcommand(argv: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + argv[0], + argv.slice(1), + { maxBuffer: 64 * 1024 * 1024, timeout: subcommandTimeoutMs, env: process.env }, + (err, stdout) => { + if (err && !stdout) { + reject(err); + return; + } + resolve(stdout ?? ""); + }, + ); + }); +} -/** - * Whether a credential read failed because the OpenCode server could - * not be reached at all, as opposed to the server answering and - * refusing. Ported from the quota CLI's commands-quota.js - * isTransportFailure (#2490): the OpenCode client marks its own - * transport failures with `reason: 'Transport'` (an HTTP answer is a - * different reason and stays authoritative); the walk down the cause - * chain covers the fetch and socket errors underneath. - */ -function isTransportFailure(error: unknown): boolean { - let current: unknown = error; - for (let depth = 0; current && depth < 8; depth += 1) { - const rec = current as { reason?: unknown; code?: unknown; message?: unknown; cause?: unknown }; - if (rec.reason === "Transport") return true; - if (typeof rec.code === "string" && TRANSPORT_ERROR_CODES.has(rec.code)) return true; - const message = typeof rec.message === "string" ? rec.message : ""; - if (message.startsWith("Transport:") || message === "fetch failed") return true; - current = rec.cause; +/** Parse one subcommand run's stdout into provider results. The + * contract (opencode#1718): --json emits an array of result objects, + * one per credential instance; an object keyed by providerId is + * also accepted. Anything else is a tick-failing defect — a silent + * empty tick is exactly what the single-source rule exists to + * prevent. */ +function parseSubcommandResults(stdout: string, label: string): QuotaProviderResult[] { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`opencode quota (${label}): unparseable output`); } - return false; + const arr = Array.isArray(parsed) + ? parsed + : typeof parsed === "object" && parsed !== null + ? Object.values(parsed as Record) + : null; + if (!arr) throw new Error(`opencode quota (${label}): output is not a result set`); + const results = arr.filter( + (r): r is QuotaProviderResult => + typeof r === "object" && r !== null && typeof (r as QuotaProviderResult).providerId === "string", + ); + if (results.length === 0) throw new Error(`opencode quota (${label}): no provider results`); + return results; } /** - * The server-first credential source wired into the quota module: - * the OpenCode server answers (GET /api/credential via the module's - * openCodeCredentialSource); the direct local-store read stands - * behind it ONLY for transport failures — the quota CLI's - * createFallbackCredentialSource shape (#2490), kept here so the - * gatherer does not import CLI plumbing. The first transport failure - * switches every later read this tick to the local store (a server - * that is down does not come back mid-tick) and is logged once, so a - * tick's provenance is never ambiguous. This is the #1251 resilience - * contract: server-down degrades to the store read, and if the store - * also fails the error propagates to failTick — the tick fails - * loudly and last-known rows stand (#1268), never zeros. + * THE tick's only quota read: the subcommand's configured listing, + * plus the explicit ask for the route-disabled live pools + * (EXPLICIT_ASK_PROVIDERS). Explicit results replace listing results + * for the same provider + credential. Results are grouped into one + * run per provider id, mapped through the unchanged quota-gather-map + * contract. */ -function createServerFirstCredentialSource( - primary: CredentialSourceLike, - fallback: CredentialSourceLike, -): CredentialSourceLike { - let fellBack = false; - const source: CredentialSourceLike = { - usesLocalStore: () => primary.usesLocalStore?.() ?? false, - getLaunchEnvironment: () => primary.getLaunchEnvironment?.() ?? null, - list: async () => { - if (fellBack) return fallback.list(); - try { - return await primary.list(); - } catch (error) { - if (!isTransportFailure(error)) throw error; - fellBack = true; - console.log( - "quota-gatherer: OpenCode server unreachable — credentials from the local OpenCode store this tick (transport fallback)", - ); - try { - return await fallback.list(); - } catch (fallbackError) { - const reason = error instanceof Error ? error.message : "OpenCode server unreachable"; - const storeReason = - fallbackError instanceof Error ? fallbackError.message : "local credential store unreadable"; - throw new Error(`${reason}; the local credential store could not be read either (${storeReason})`); - } - } - }, +async function collectRuns(tickAt: string): Promise { + const listingT0 = Date.now(); + const listingStdout = await execSubcommand(subcommandArgv([])); + const listing = parseSubcommandResults(listingStdout, "listing"); + const listingMs = Date.now() - listingT0; + + const askT0 = Date.now(); + const askStdout = await execSubcommand(subcommandArgv(EXPLICIT_ASK_PROVIDERS)); + const asked = parseSubcommandResults(askStdout, "explicit ask"); + const askMs = Date.now() - askT0; + + // Per-provider group choice, the pre-rewire discipline generalized: + // a listing group with an ok row wins (the ask is not consulted for + // it); otherwise a fresher ask group with an ok row replaces it; + // otherwise the ask group (fresher error) stands over the listing's. + // The two invocations key credentials differently (the listing + // enumerates stored credentials; a named ask may answer with a + // credential-less result), so merging per-credential would publish + // the same pool twice — once as each invocation saw it. + const groupByProvider = (results: QuotaProviderResult[], ms: number) => { + const groups = new Map(); + for (const result of results) { + const group = groups.get(result.providerId) ?? { results: [], ms }; + group.results.push(result); + groups.set(result.providerId, group); + } + return groups; }; - if (primary.create) source.create = (input) => primary.create!(input); - if (primary.remove) source.remove = (credentialID) => primary.remove!(credentialID); - if (primary.listEnvironmentKeys) source.listEnvironmentKeys = () => primary.listEnvironmentKeys!(); - return source; + const listingGroups = groupByProvider(listing, listingMs); + const askGroups = groupByProvider(asked, askMs); + const byProvider = new Map(); + for (const [id, group] of listingGroups) byProvider.set(id, group); + for (const [id, askGroup] of askGroups) { + const listed = byProvider.get(id); + if (!listed) { + byProvider.set(id, askGroup); + continue; + } + const listedOk = listed.results.some((r) => r.ok); + const askOk = askGroup.results.some((r) => r.ok); + if (!listedOk) byProvider.set(id, askGroup); + else if (!askOk) { + console.log(`quota-gatherer: ${id} ok via listing — explicit ask's error row dropped`); + } + // Both ok: the listing group stands — it is the canonical, + // credential-keyed enumeration; the ask exists to rescue a + // missing or errored listing group, not to override a good one. + } + + const runs: ProviderRun[] = []; + for (const [id, group] of byProvider) { + const t0 = Date.now() - group.ms; + const entries = group.results.map((r) => entryFromResult(r, tickAt)); + let run = finalizeRun(id, entries, t0); + if (id === "command-code") run = keepCommandCodePlanWindows(run); + runs.push(run); + } + return runs; } -async function main(): Promise { - const started = Date.now(); - const tickAt = new Date(started).toISOString(); +const SUPABASE_TOKEN_NAME = "supabase.muse-dev2.access-token-muse-cli"; +const SUPABASE_REF_NAME = "supabase.muse-dev2.project-ref"; - let quota: QuotaModule; +function sqlString(v: string): string { + return `'${v.replace(/'/g, "''")}'`; +} + +/** + * The Supabase sink: one agent.quota_records row per pool entry that + * carries a reading (at least one window), in the shape the retired + * "pm-pool" writer used (payload = {id, ts, plan, state, source, + * windows[{name, percent, resetsAt}], provider}), with source = + * GATHERER_SOURCE naming the single source. Runs AFTER + * quota-current.json is written; fail-soft by contract (a sink + * failure is logged, never fails the tick, never zeroes a row). + */ +async function writeSupabaseRecords(entries: QuotaCurrentEntry[], tickAt: string): Promise { + if (process.env.SUPABASE_QUOTA_SINK === "0") return; try { - const auth = (await import(join(libDir, "opencode/auth.js"))) as unknown as AuthModule; - // Ask the OpenCode server first (GET /api/credential on the - // standing fork server); the direct store read is only the - // transport-failure fallback behind it. See - // createServerFirstCredentialSource and the header (family 1). - const server = await resolveOpenCodeServer(); - const serverSource = auth.openCodeCredentialSource({ - buildOpenCodeUrl: (requestPath?: string) => `${server.baseUrl}${requestPath ?? ""}`, - getOpenCodeAuthHeaders: server.authHeaders, + const token = await resolveBwsSecret(SUPABASE_TOKEN_NAME); + const ref = await resolveBwsSecret(SUPABASE_REF_NAME); + if (!token || !ref) { + console.log("quota-gatherer: supabase sink skipped — token/ref unresolved by name"); + return; + } + const readable = entries.filter((e) => e.windows.length > 0); + if (!readable.length) { + console.log("quota-gatherer: supabase sink — no entries with windows this tick"); + return; + } + const values = readable.map((e) => { + const weekly = e.windows.find((w) => w.name === "weekly"); + const payload = { + id: e.serviceId, + ts: e.measuredAt, + plan: e.planLabel ?? null, + state: e.state, + source: GATHERER_SOURCE, + windows: e.windows.map((w) => ({ name: w.name, percent: w.pctOfLimit, resetsAt: w.resetsAt })), + provider: e.providerName, + }; + const id = `${e.serviceId}:${tickAt}`; + return `(${sqlString(id)}, ${sqlString(GATHERER_SOURCE)}, ${sqlString(e.measuredAt)}, ${ + e.planLabel ? sqlString(e.planLabel) : "NULL" + }, ${weekly?.pctOfLimit ?? "NULL"}, ${weekly?.resetsAt ? sqlString(weekly.resetsAt) : "NULL"}, ${sqlString( + JSON.stringify(payload), + )}::jsonb)`; }); - auth.configureOpenCodeCredentials( - createServerFirstCredentialSource(serverSource, auth.openCodeLocalCredentialSource({ dbPath })), - ); - quota = (await import(join(libDir, "quota/index.js"))) as unknown as QuotaModule; + const query = + "insert into agent.quota_records (id, source, ts, plan, weekly_percent, resets, payload) values " + + values.join(", ") + + " on conflict (source, id, ts) do nothing;"; + const res = await fetch(`https://api.supabase.com/v1/projects/${ref}/database/query`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }); + if (!res.ok) { + console.log(`quota-gatherer: supabase sink failed-soft: HTTP ${res.status}`); + return; + } + console.log(`quota-gatherer: supabase sink wrote ${readable.length} quota_records rows (source=${GATHERER_SOURCE})`); } catch (err) { - return failTick(tickAt, started, `quota module import failed: ${err instanceof Error ? err.message : String(err)}`); + console.log( + `quota-gatherer: supabase sink failed-soft: ${safeProviderReason(err instanceof Error ? err.message : String(err))}`, + ); } +} - // Family 6: renew the OpenDesign session before its read. +async function main(): Promise { + const started = Date.now(); + const tickAt = new Date(started).toISOString(); + + // The OpenDesign keeper runs first: a session renewed this tick is + // the one the subcommand's OpenDesign read uses. await runOpenDesignRenewal(tickAt); - let providerIds: string[]; + // The single source: every reading this tick comes from the + // opencode quota subcommand. A failure to run it, or output that + // cannot be parsed, fails the tick loudly and the previous + // quota-current.json stands. + let runs: ProviderRun[]; try { - providerIds = await quota.listConfiguredQuotaProviders(); + runs = await collectRuns(tickAt); } catch (err) { return failTick( tickAt, started, - `credential store read failed: ${err instanceof Error ? err.message : String(err)}`, + `opencode quota subcommand failed: ${err instanceof Error ? err.message : String(err)}`, ); } - // #1268: bounded provider concurrency (default 6, matching the - // OpenChamber CLI) instead of one Promise.all over the catalogue. - const runs = await mapWithConcurrency(providerIds, gatherConcurrency, (id) => runProvider(quota, id, tickAt)); - for (const fx of FILE_EXPORTS) runs.push(await runFileExport(fx)); - runs.push(await runTurso(tickAt)); - - // #1159: register the native ClinePass adapter. Exactly one - // cline-pass run survives: a fresh module row wins and the native - // adapter is never even started (no duplicate credential resolve - // or dashboard fetch that tick); otherwise the native run - // replaces the module's errored run so #1268 retention below - // applies to one run only. - const clineIdx = runs.findIndex((r) => r.id === CLINEPASS_PROVIDER_ID); - if (clineIdx !== -1 && runs[clineIdx].entries.some((e) => e.state === "ok")) { - console.log("quota-gatherer: cline-pass ok via openchamber module - native adapter run skipped"); - } else { - const clineRun = await runClinePass(tickAt); - if (clineRun) { - if (clineIdx === -1) runs.push(clineRun); - else runs[clineIdx] = clineRun; - } - } - - // 2026-11-03: the Meta module ask (see runMetaModule). Exactly one - // meta run survives, mirroring the ClinePass discipline above: a - // fresh listed run (the door re-enabled someday) wins and the ask - // is skipped; otherwise the ask replaces the listed run, or is - // appended when the listing never named meta. - const metaIdx = runs.findIndex((r) => r.id === "meta"); - if (metaIdx !== -1 && runs[metaIdx].entries.some((e) => e.state === "ok")) { - console.log("quota-gatherer: meta ok via provider listing - explicit module ask skipped"); - } else { - const metaRun = await runMetaModule(quota, tickAt); - if (metaIdx === -1) runs.push(metaRun); - else runs[metaIdx] = metaRun; - } - - // Command Code: exactly one run survives, mirroring Meta above. - // A listed run with a measured plan window wins (balance windows - // stripped); otherwise the explicit ask replaces or appends it, - // so a routing-disabled provider still gets its plan read. - const ccIdx = runs.findIndex((r) => r.id === "command-code"); - if (ccIdx !== -1 && runs[ccIdx].entries.some((e) => e.state === "ok" && entryHasMeasuredWindow(e))) { - keepCommandCodePlanWindows(runs[ccIdx]); - console.log("quota-gatherer: command-code ok via provider listing - explicit module ask skipped"); - } else { - const ccRun = await runCommandCodeModule(quota, tickAt); - if (ccIdx === -1) runs.push(ccRun); - else runs[ccIdx] = ccRun; - } - - // The Claude auth source exposes an expiry timestamp. Persist only that - // metadata and its source class, never the credential object or values. - const claudeRun = runs.find((run) => run.id === "claude"); - if (claudeRun?.entries.length) { - const metadata = await claudeExpiryMetadata(); - if (metadata) { - for (const entry of claudeRun.entries) { - if (metadata.expiresAt) entry.credentialExpiresAt = metadata.expiresAt; - if (metadata.source) entry.credentialSource = metadata.source; - } - } - } - // #1268: an errored run with no ok row of its own retains the // provider's last-known ok entries from the previous tick instead // of replacing them with fresh error rows (or with nothing, on a @@ -1048,6 +681,11 @@ async function main(): Promise { console.error(`quota-gatherer: state write failed (${err}); next tick may re-append`), ); } + + // Supabase: the same readings land in agent.quota_records on the + // same tick (Drew's regular-basis rule). Fail-soft, after the file. + await writeSupabaseRecords(entries, tickAt); + const okCount = runs.filter((r) => r.state === "ok").length; console.log( `quota-gatherer: tick ${tickAt} providers=${runs.length} ok=${okCount} records=+${fresh.length} durationMs=${heartbeat.durationMs} -> ${currentPath}`, diff --git a/pm-stack/aggregator/src/quota-gather-map.ts b/pm-stack/aggregator/src/quota-gather-map.ts index 5540bb724..abf41e5fe 100644 --- a/pm-stack/aggregator/src/quota-gather-map.ts +++ b/pm-stack/aggregator/src/quota-gather-map.ts @@ -107,7 +107,7 @@ export function serviceIdFor(providerId: string): string { } } -export const GATHERER_SOURCE = "openchamber-quota"; +export const GATHERER_SOURCE = "opencode-quota"; /** * Split pool service ids the gatherer emits alongside the combined rows