feat(seats): an API seat's cycle starts on its own grant day
All checks were successful
Flatpak / check (push) Has been skipped
Flatpak / publish (push) Successful in 5m30s

The ledger reset every API seat's spend on one
REVIEW_GATES_API_CYCLE_DAY and cleared every credit-out, an OpenRouter
key's too, at that reset. Each API seat's cycle starts on its
ANTHROPIC_API_KEY<n>_GRANT_DAY now (1..31, a day past a month's end its
last), else the cycle day, also 1..31. A credit-out carries across the
rollover, and the run's probe tries a key held out on credit again, as
`seats` does an API key: an ok lifts the mark - an API key's is new
credits, its cycle restarting from the grant day, or from today when
that day is over a week back; an OpenRouter key keeps its cycle's spend,
and a key that answers with its cycle's budget spent stays out on that
budget. A probe that finds a key out of credit records it, unless the
ledger already has it out. Both are decided in one locked update on the
file's entry, a mark another driver made since the probe started
standing, and the ledger's stamps carry their UTC offset so marks are
ordered by instant.

`seats` shows the probe's reading beside the driver's verdict (the
`probe` and `driver` columns replace `state` and `ledger: out`), each
seat's grant day and when its next credits are due, and notes on what
the probe changed in the ledger or why the driver's verdict stands; the
run logs the days.
This commit is contained in:
Jorijn van der Graaf 2026-11-02 13:58:29 +01:00
commit ceb87f70ef
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
12 changed files with 774 additions and 83 deletions

View file

@ -534,7 +534,8 @@ inventory unit, against a frozen commit range.)";
constexpr std::string_view Prog = "crafter-review seats";
const Parsed parsed = ParseOptions(args, SeatsOptions);
if (parsed.help) {
std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 from the environment and/or the dotenv file. Never prints a token.\n",
std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 and ANTHROPIC_API_KEY1..6 from the environment and/or the dotenv file. Never prints a token. An API key's "
"probe is recorded in the seat ledger: new credits, or the credit error.\n",
Help(Prog, "How much usage each Claude seat has left: every seat probed, both windows and their resets.", SeatsOptions));
return 0;
}
@ -603,9 +604,23 @@ inventory unit, against a frozen commit range.)";
Seats::ProbeOptions api = haiku;
api.api = true;
apiSeats = ProbeAll(todo, api);
// The driver's ledger: what it spent on each key this cycle.
SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig);
for (Json& s : apiSeats) s["ledger"] = ledger.Entry(std::format("api{}", s.at("seat").get<std::int64_t>()));
// The driver's ledger: what it spent on each key this cycle,
// from each seat's grant day. It follows the probe: a key that
// answers again after the credit error is new credits, the
// credit error on a key it has as fine is recorded.
std::map<std::string, std::int32_t> days;
for (const auto& [n, day] : Seats::ApiGrantDays(env)) days[std::format("api{}", n)] = day;
SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig, days);
for (Json& s : apiSeats) {
const std::string id = std::format("api{}", s.at("seat").get<std::int64_t>());
s["grantDay"] = ledger.Day(id);
// `now`, taken before the probes started, is their start;
// what Reconcile writes is stamped when it is written.
s["event"] = ledger.Reconcile(id, s.at("state"), now);
s["ledger"] = ledger.Entry(id, now);
s["ledgerOut"] = SeatPool::LedgerOut(s.at("ledger"), apiConfig.at("budgetUsd").get<double>());
s["nextCredits"] = SeatPool::NextGrant(now, ledger.Day(id), Dyn::Get(s.at("ledger"), "outSince"));
}
}
// The board: the drivers running now and what they hold on each
// account - read, never written.
@ -631,7 +646,7 @@ inventory unit, against a frozen commit range.)";
for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get<std::int64_t>()));
std::println("seat pool: {} seat(s) ({}) from {}, probed {} {}, claude: {}\n", ids.size(), Dyn::Join(ids, ", "), source, Seats::Clock(now, zone), zone.label, claudeBin);
if (!seats.empty()) std::println("{}", Seats::RenderStatusReport(seats, cfg, now, zone, board, keys));
if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, board, apiKeys));
if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, now, board, apiKeys));
std::println("\n{}", Seats::RenderDrivers(board, now, zone));
// The pids above are no use from inside the sandbox: the way to
// stop a run instead.