Anthropic is removing the 5-hour limit in stages (from 2026-10-31), and an account without it reports the weekly window alone. Until now the pool took such an account for an unprobed seat: always eligible, ranked after every other, never held to the weekly cutoff, with no cap on its passes. - The burst window is the 5-hour one, else the weekly one (Seats::BurstWindow). An account whose burst window is the weekly one is ranked by it and admitted under the 95 % cutoff with the passes in flight projected on it, at the measured share per pass (0.04 at xhigh, 0.125 otherwise; REVIEW_GATES_LENS_COST_7D_PCT), and waits for the weekly reset when over it. - Every OAuth account and the local login takes at most 128 passes at once, every driver's counted (REVIEW_GATES_OAUTH_CONCURRENCY): every run measured with 149 or more in flight on one account drew usage-limit rejections, none of the 119 with 139 or fewer. - A 'concurrents' rejection pauses the seat for 20 s or its retry-after, not until a window's reset (a seat sat two hours at 10 % on one). - Board entries, the folded foreign record and the board's view carry cost7d, so the drivers see each other's weekly load. - `seats` and the log table say `no limit` in such an account's 5-hour column; the board's others-line names its weekly projection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
13 KiB
C++
224 lines
13 KiB
C++
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
export module Crafter.Review:SeatPool;
|
|
import std;
|
|
import :Json;
|
|
|
|
// The seat pool: which credential each lens process runs on. OAuth seats
|
|
// (CLAUDE_CODE_OAUTH_TOKEN1..6), API-credit seats (ANTHROPIC_API_KEY1..6,
|
|
// real money, a ledger and a budget), OpenRouter seats
|
|
// (OPENROUTER_KEY[1..6], never in the default pool), and `local`, this
|
|
// machine's login. Placement: API seats first (their credit expires monthly),
|
|
// then OAuth seats by projected utilisation; a process that fails on a seat's
|
|
// limit, credit or auth is placed again; the pool aborts only when every seat
|
|
// is out. Secrets never reach a log, a dump or a child's environment except
|
|
// the one the CLI reads.
|
|
//
|
|
// Several drivers at once (2026-10-31): every driver on this machine
|
|
// registers on the board (~/.cache/review-gates/seat-board.json) what it has
|
|
// in flight on each seat, and folds the board into its seats and admission
|
|
// before every launch; the pick is registered under the same lock. Every
|
|
// driver on the machine shares the file, its shape and its lock. A seat is a
|
|
// JSON object: the dump records it (without the secret).
|
|
//
|
|
// Liveness (README, AGENTS.md): a driver is alive while its lifetime lock
|
|
// (<board dir>/drivers/<pid>-<start>.lock) is held; without one, natively
|
|
// the pid and start time decide, and in the Flatpak the driver is taken for
|
|
// alive, because pids of another namespace say nothing here.
|
|
export namespace Crafter::Review::SeatPool {
|
|
// A job is re-placed this often after a limit, credit or auth failure.
|
|
constexpr std::int32_t MaxAttempts = 4;
|
|
// An API seat's pause after a 429.
|
|
constexpr double ApiBackoffS = 90;
|
|
constexpr double OauthFallbackPauseS = 300;
|
|
// An OAuth seat's pause after a 'concurrents' rejection: too many requests
|
|
// at once on the account, cleared as the ones in flight answer. Not the
|
|
// window's reset (2026-10-31: a seat sat two hours at 10 % on one).
|
|
constexpr double ConcurrentBackoffS = 20;
|
|
// The passes one OAuth account (or the local login) takes at once, every
|
|
// driver's counted. Until 2026-10-31 the 5-hour projection was the only
|
|
// limit, and an account without a 5-hour limit has none: on the record,
|
|
// every run with 149 or more in flight on one account drew usage-limit
|
|
// rejections, none of the 119 with 139 or fewer.
|
|
// REVIEW_GATES_OAUTH_CONCURRENCY overrides.
|
|
constexpr std::int64_t OauthConcurrency = 128;
|
|
// The kinds whose processes cost money the ledger counts.
|
|
constexpr std::array<std::string_view, 2> Paid = {"api", "openrouter"};
|
|
// The kinds whose out/invalid verdict crosses drivers on the board.
|
|
constexpr std::array<std::string_view, 2> StateShared = {"oauth", "local"};
|
|
constexpr std::string_view BoardFile = "seat-board.json";
|
|
// An in-flight entry outlives its pass's time budget x1.5 by this much
|
|
// before the sweep takes it.
|
|
constexpr double InflightGraceS = 300;
|
|
constexpr std::string_view OpenRouterBaseUrl = "https://openrouter.ai/api";
|
|
constexpr std::string_view OpenRouterModelsUrl = "https://openrouter.ai/api/v1/models";
|
|
constexpr std::string_view OpenRouterKeyUrl = "https://openrouter.ai/api/v1/key";
|
|
extern const std::string_view ResumePrompt;
|
|
|
|
bool IsPaid(const Json& seat);
|
|
// The API-seat knobs.
|
|
Json ApiConfig(const std::map<std::string, std::string>& env);
|
|
// The other drivers' load on a seat as the last board fold left it.
|
|
Json Foreign(const Json& seat);
|
|
std::filesystem::path LedgerPath(const std::optional<std::filesystem::path>& scratchRoot = std::nullopt);
|
|
// The most recent date with that day of the month, as YYYY-MM-DD.
|
|
std::string CycleStart(double now, std::int32_t day);
|
|
|
|
// What the driver spent on each API seat this cycle, and whether the key
|
|
// answered the credit error; one file for every driver, each write a delta
|
|
// applied under its lock.
|
|
class Ledger {
|
|
public:
|
|
Ledger(std::filesystem::path path, Json apiConfig, Json data = Json::object());
|
|
static Ledger Load(const std::filesystem::path& path, const Json& apiConfig);
|
|
Json& Entry(const std::string& seatId, std::optional<double> now = std::nullopt);
|
|
Json Add(const std::string& seatId, double usd, std::optional<double> now = std::nullopt);
|
|
Json MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now = std::nullopt);
|
|
void Save();
|
|
const Json& Data() const;
|
|
const std::filesystem::path& Path() const;
|
|
|
|
private:
|
|
std::filesystem::path path;
|
|
Json apiConfig;
|
|
Json data;
|
|
Json Update(const std::string& seatId, const std::function<void(Json&)>& change, std::optional<double> now);
|
|
};
|
|
|
|
Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret);
|
|
// The seats a run may use. spec: auto (every Anthropic seat of the env
|
|
// file and the environment, else local), local | api | oauth | openrouter,
|
|
// or a list such as api1,1,2,local. std::invalid_argument with a
|
|
// ValueError message for a seat that is not configured, or OpenRouter
|
|
// mixed with the rest.
|
|
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, const std::optional<std::filesystem::path>& envFile, std::string_view spec,
|
|
Ledger* ledger = nullptr, const Json& apiConfig = nullptr);
|
|
using ProbeFunction = std::function<Json(std::int32_t seat, const std::string& token, const std::string& model, double timeout, const std::string& claudeBin, bool local, bool api)>;
|
|
// One probe per seat before the run; a seat already out is skipped.
|
|
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models,
|
|
const std::function<void(const std::string&)>& log, const ProbeFunction& probe = {});
|
|
void ApplyProbe(Json& seat, const Json& result);
|
|
std::string Describe(const Json& seat);
|
|
|
|
// The OAuth placement knobs, with the pool's own per-process projections:
|
|
// 0.25 of the 5-hour window (the 2026-10-15 load test moved a 20x seat's
|
|
// window 0.08 points a pass) and, for an account without a 5-hour limit,
|
|
// the measured share of the weekly window at the run's effort (0.04 at
|
|
// xhigh, 0.125 otherwise; REVIEW_GATES_LENS_COST_7D_PCT); and the
|
|
// per-account cap.
|
|
Json PoolConfig(const std::map<std::string, std::string>& env, std::string_view effort = {});
|
|
double OauthProjected(const Json& seat, const Json& config);
|
|
// A window whose reset has passed counts as empty until the next reading.
|
|
std::vector<std::string> ExpireWindows(Json& seat, double now);
|
|
bool Eligible(Json& seat, double now, const Json& config, const Json& apiConfig);
|
|
// Every seat with room for a launch now, best first (pointers into seats).
|
|
std::vector<Json*> Ranked(std::vector<Json>& seats, double now, const Json& config, const Json& apiConfig);
|
|
Json* Pick(std::vector<Json>& seats, double now, const Json& config, const Json& apiConfig);
|
|
|
|
std::filesystem::path SeatDir(const std::filesystem::path& scratch, std::string_view seatId);
|
|
// A pass interrupted by its seat's limit continues on another seat once
|
|
// its transcript is copied into that seat's config dir.
|
|
std::optional<std::filesystem::path> CarryTranscript(const std::filesystem::path& scratch, std::string_view fromSeat, std::string_view toSeat,
|
|
std::string_view sessionId, const std::function<void(const std::string&)>& log = {});
|
|
bool AllOut(const std::vector<Json>& seats);
|
|
std::optional<double> EarliestResume(const std::vector<Json>& seats, double now, std::optional<double> recheck = std::nullopt);
|
|
// A finished process onto its seat; true when the job must be placed again
|
|
// (the seat failed it, not the lens).
|
|
bool Settle(Json& seat, Json& record, double now, const Json& config, const Json& apiConfig, Ledger* ledger, const std::function<void(const std::string&)>& log);
|
|
double RetryAfter(std::string_view text, double fallback);
|
|
|
|
std::filesystem::path WriteCredentials(const std::filesystem::path& configDir, std::string_view token);
|
|
// The environment of a lens process on this seat.
|
|
std::map<std::string, std::string> ChildEnv(const Json& seat, const std::map<std::string, std::string>& base, const std::filesystem::path& scratch);
|
|
|
|
std::string ModelKey(std::string_view model);
|
|
// {catalogue id: prices or null} of the run's models.
|
|
Json FetchOpenRouterPrices(const std::vector<std::string>& models, const std::function<void(const std::string&)>& log = {}, double timeout = 20);
|
|
double OpenRouterCost(std::string_view model, const Json& usage);
|
|
Json ProbeOpenRouter(const std::string& secret, double timeout = 15);
|
|
|
|
// ---- several drivers at once: the board --------------------------------------
|
|
std::filesystem::path BoardPath();
|
|
// `<kind>:<12 hex of the secret's sha256>`, `local` for the login.
|
|
std::string SeatKey(std::string_view kind, std::string_view secret);
|
|
// This driver as the board names it: `<pid>:<start>`, the sandbox
|
|
// instance's in the Flatpak (Platform::Self).
|
|
std::string MyKey();
|
|
// Is the driver `key` alive? Its lifetime lock decides when it has one
|
|
// (a free one is taken away when `reap`); else natively its pid and start
|
|
// time, and in the sandbox yes.
|
|
bool DriverAlive(std::string_view key, const std::filesystem::path& boardDir, bool reap = true);
|
|
// One live driver per scratch dir: a `.driver` marker, taken over when its
|
|
// driver is dead.
|
|
bool ClaimScratch(const std::filesystem::path& path);
|
|
// Fold `other`'s readings into `into`, in place; the windows that changed.
|
|
std::vector<std::string> MergeWindows(Json& into, const Json& other, double now);
|
|
|
|
// The other drivers' passes in flight on this machine.
|
|
struct Others {
|
|
std::int64_t count = 0;
|
|
double mb = 0;
|
|
std::set<std::string> stacks;
|
|
std::int64_t unmeasured = 0;
|
|
};
|
|
|
|
class Board {
|
|
public:
|
|
explicit Board(std::filesystem::path path, std::string me = {}, std::optional<std::string> boot = std::nullopt,
|
|
std::function<void(const std::string&)> log = {});
|
|
// Is the driver `key` on the board alive: this driver; else not when
|
|
// it joined under another boot; else DriverAlive.
|
|
bool Alive(std::string_view key, const Json& drivers, bool reap = true) const;
|
|
// Drop what no longer stands: dead drivers with their in-flight
|
|
// entries, entries past their deadline, verdicts past their expiry
|
|
// or whose driver is gone, seat records with nothing live. (changed,
|
|
// the lines that say what went).
|
|
std::pair<bool, std::vector<std::string>> Sweep(Json& data, double now, bool reap = true) const;
|
|
void Join(const Json& info, double now);
|
|
// Everything of this driver off the board; idempotent, never throws.
|
|
void Leave();
|
|
void Publish(const std::vector<Json>& seats, double now, bool probed = false);
|
|
std::vector<std::string> Fold(std::vector<Json>& seats, double now);
|
|
static std::int64_t TotalInflight(const Json& data);
|
|
// The memory a gate's budget starts from, and how it was had.
|
|
std::pair<double, std::string> MemoryBase(std::int64_t free, double now);
|
|
using Chooser = std::function<std::optional<std::pair<Json*, Json*>>(const std::vector<Json*>& ranked, const Others& others)>;
|
|
struct Placed {
|
|
std::vector<Json*> ranked;
|
|
std::optional<std::pair<Json*, Json*>> picked;
|
|
std::int64_t full = 0;
|
|
};
|
|
// One locked section: the board folded, the seats ranked, the
|
|
// chooser's pick registered before the lock drops.
|
|
Placed Place(std::vector<Json>& seats, double now, const Json& config, const Json& apiConfig, const Chooser& chooser, std::optional<std::int64_t> cap = std::nullopt);
|
|
void Finish(const Json& seat, std::string_view label, double now);
|
|
// The board as it stands, swept in memory, nothing written.
|
|
Json View(double now) const;
|
|
std::vector<std::string> OthersLines(const std::vector<Json>& seats, double now, const Json& config, const Json& apiConfig, std::optional<std::int64_t> cap = std::nullopt) const;
|
|
const Others& OthersNow() const;
|
|
const std::string& Me() const;
|
|
const std::filesystem::path& Path() const;
|
|
const Json& Data() const;
|
|
|
|
private:
|
|
std::filesystem::path path;
|
|
std::string me;
|
|
std::string boot;
|
|
std::function<void(const std::string&)> log;
|
|
Json data;
|
|
Others others;
|
|
bool warned = false;
|
|
void Warn(const std::string& message);
|
|
Json Update(const std::function<bool(Json&)>& change);
|
|
Json& Record(Json& data, const Json& seat) const;
|
|
static bool StateHolds(const Json& state, const Json& data, double now);
|
|
Others OthersOf(const Json& data) const;
|
|
std::pair<bool, std::vector<std::string>> FoldInto(std::vector<Json>& seats, double now, Json& data);
|
|
bool PublishSeat(Json& data, const Json& seat, double now, bool probed) const;
|
|
};
|
|
|
|
// The seats as the dump records them: no secrets.
|
|
Json Summary(const std::vector<Json>& seats);
|
|
std::string Table(const std::vector<Json>& seats, std::optional<double> now = std::nullopt);
|
|
}
|