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

@ -4,6 +4,8 @@
// Seat usage: the pure functions against reference vectors, numbered in
// order, so "check 17" always names the same vector.
#include <stdlib.h>
#include <time.h>
import std;
import Crafter.Review;
namespace fs = std::filesystem;
@ -45,6 +47,10 @@ namespace {
}
int main() {
// The API report's dates (NextGrant, its "today") are the process zone's,
// as for dt.date.fromtimestamp: the vectors below are UTC's.
setenv("TZ", "UTC", 1);
tzset();
const Zone utc = ZoneNamed("UTC");
const Zone ams = ZoneNamed("Europe/Amsterdam");
// 2026-09-07 10:00 UTC
@ -232,6 +238,44 @@ int main() {
Eq(ClipReason("token sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123 and jorijn@example.com x\n y", secret), "token [redacted:token] and [redacted:email] x y");
Eq(ClipReason("bad lit-secret here", secret), "bad [redacted:token] here");
Eq(LooksLikeAuthFailure({{"api_error_status", 401}}, ""), true);
// The API report (2026-10-17, ported 2026-11-02): the probe beside the
// driver's verdict, the cycle, the grant day and the next credits (today,
// a date, overdue) - the Python driver's vectors.
{
const Json acfg = SeatPool::ApiConfig({});
auto api = [&](std::int32_t n, const std::string& state, const Json& error, std::int32_t day, const Json& ledger, const Json& event = nullptr) {
Json s = {{"seat", n}, {"state", state}, {"error", error}, {"grantDay", day}, {"ledger", ledger}, {"ledgerOut", SeatPool::LedgerOut(ledger, acfg.at("budgetUsd").get<double>())},
{"nextCredits", SeatPool::NextGrant(now, day, Dyn::Get(ledger, "outSince"))}};
if (!event.is_null()) s["event"] = event;
return s;
};
auto entry = [](const std::string& cycle, double spent, std::int32_t procs, const Json& outSince) {
return Json {{"cycle", cycle}, {"spentUsd", spent}, {"procs", procs}, {"outSince", outSince}, {"outReason", outSince.is_null() ? Json() : Json("credit balance too low")}};
};
const std::string report = RenderApiReport({api(1, "out", "Your credit balance is too low", 7, entry("2026-09-07", 0.0, 0, "2026-08-20T20:19:06")),
api(3, "ok", nullptr, 7, entry("2026-09-07", 0.0, 0, nullptr), "new-grant"), api(4, "ok", nullptr, 5, entry("2026-09-05", 200.0, 100, nullptr)),
api(2, "unknown", "probe timeout after 30000 ms", 17, entry("2026-08-17", 12.5, 9, "2026-09-01T10:00:00")),
api(5, "out", "Your credit balance is too low", 3, entry("2026-09-03", 0.0, 0, "2026-08-10T10:00:00"))},
acfg, now);
std::string flat;
for (const char c : report) {
if (c != ' ' || !flat.ends_with(' ')) flat += c;
}
Eq(flat.contains("| seat | probe | driver | driver spend this cycle | procs | cycle from | grant day | next credits |"), true);
Eq(flat.contains("| api1 | out (Your credit balance is too low) | out (credit since 2026-08-20 20:19) | $0.00 of $200 | 0 | 2026-09-07 | 7 | today |"), true);
Eq(flat.contains("| api2 | unknown (probe timeout after 30000 ms) | out (credit since 2026-09-01 10:00) | $12.50 of $200 | 9 | 2026-08-17 | 17 | 17 Sept |"), true);
Eq(flat.contains("| api3 | ok | ok | $0.00 of $200 | 0 | 2026-09-07 | 7 | 7 Oct |"), true);
Eq(flat.contains("| api4 | ok | out (budget spent) | $200.00 of $200 | 100 | 2026-09-05 | 5 | 5 Oct |"), true);
Eq(flat.contains("| api5 | out (Your credit balance is too low) | out (credit since 2026-08-10 10:00) | $0.00 of $200 | 0 | 2026-09-03 | 3 | overdue, 3 Sept |"), true);
std::vector<std::string> notes;
for (const std::string& line : Lines(flat)) {
if (line.starts_with("api") && notes.size() < 4) notes.push_back(line);
}
EqJson(Json(notes), Json::array({"api2: probe unknown - the ledger's verdict stands", "api3: the key answers again - new credits; the ledger cycle restarted (from 2026-09-07)",
"api4: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)",
"api seats run first (REVIEW_GATES_SEAT_ORDER=api-first) · budget $200 per seat per cycle · a cycle starts on the seat's grant day "
"(ANTHROPIC_API_KEY<n>_GRANT_DAY, else REVIEW_GATES_API_CYCLE_DAY=1) · 8 processes per seat · placed at +$1 each"}));
}
Eq(LooksLikeAuthFailure(nullptr, "Invalid API key · Please run /login"), true);
Eq(LooksLikeAuthFailure({{"result", "ok"}}, ""), false);
// find_claude_bin: CLAUDE_BIN, then PATH, then the newest editor-bundled