Crafter.Review/interfaces/Crafter.Review-SeatPool.cppm
Jorijn van der Graaf c63c898833
feat(seatpool): the seat pool and the board several drivers share
The seats of .env and the ledger, the probe, placement, settling a
finished pass, the child's credentials, the OpenRouter seats, and the
board every driver on the machine shares, in one shape and under one
lock. A driver is alive while its lifetime lock is held; with no lock file
natively its pid and start time decide, and in the sandbox it is taken for
alive.

:Http is the HTTP the driver needs, on FetchHTTP1: a status outside 2xx is
HttpError, every other failure carries a fixed text. :Platform gains
MakeDirs, with the last directory's own mode, and errno texts for its
writes and locks.

The tests cover the seat pool and several drivers at once, less the checks
that drive the pool's Scheduler, Admission and Stacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:15:03 +01:00

208 lines
12 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;
// 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;
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 default (0.25:
// the 2026-10-15 load test moved a 20x seat's window 0.08 points a pass).
Json PoolConfig(const std::map<std::string, std::string>& env);
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);
}