Compare commits

..
Author SHA1 Message Date
ceb87f70ef
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.
2026-11-02 18:44:02 +01:00
c6b4d78245
feat(freeze): no authors in the config, no author check
A repository that takes commits from people it cannot list has no way
to freeze a range today: a null `authors` falls back to
`git config user.email`, so every foreign author or committer fails.
Null, or no key, now means neither is checked; the freeze warns that it
did not check them and records `authors` as null. A list is checked as
before. An empty list, which no commit could pass, is a config error
that points at null.
Released as 1.1.0.
2026-11-02 17:16:07 +01:00
20 changed files with 826 additions and 132 deletions

View file

@ -111,7 +111,7 @@ Conventions:
No file the drivers write holds these in practice, and a file that fails to parse is treated as a damaged one.
- **HTTP.** `ClientHTTP1::Fetch` ignores `*_proxy` variables. The OpenRouter requests send `User-Agent: crafter-review`; `app_ready` sends `review-gates`. Reason phrases are the standard ones, not the server's.
- **Seat placement** (SeatPool, Seats, Drivers): The settings beside the seats count too. `run` (Gate 0) and `seats` each resolve the seats file from their own `--env-file` with `SeatPool::ResolveSeatsFile` (`--env-file`, `$SEATS_ENV_FILE`, `<cwd>/.env` when it is a file holding a seat or a placement setting, each with a value (a blank line is neither), or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application `.env`, or one that is no file, is passed over, recorded as `passedOver` and logged by the run - the machine's own), reading each file once (`SeatsFile::Text()`: a pipe or a FIFO `--env-file` gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment, `SeatPool::WithFileTokens` - the one place, so `LoadSeats` takes the merged environment and reads no file). `SeatPool::SettingsOf` lays the file's placement settings (`Seats::SettingsFromEnvFile`, one dotenv grammar with the tokens', `DotenvLines`; `SeatPool::PlacementSettings`, the fifteen names the readers read, also deciding what makes `<cwd>/.env` a seats file - a test pins that each moves a reader) under the process environment into a map handed to the placement readers alone - `ApiConfig`, `PoolConfig`, `Seats::ConfigFromEnv` - names its other `REVIEW_GATES_*` lines (the run logs them, `seats` prints them on stderr), and the process environment is not touched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one `.env` keep one board and one ledger (the Python driver's `config_env` of 2026-10-17; the first self-review's rounds 3 to 10, 2026-11-01, reworked it as an application to the process environment until round 11 found the map the readers already took; the per-seat `ANTHROPIC_API_KEY<n>_GRANT_DAY` cycle days are still unported: one `REVIEW_GATES_API_CYCLE_DAY` for every API seat) (SeatPool).
- **Seat placement** (SeatPool, Seats, Drivers): The settings beside the seats count too. `run` (Gate 0) and `seats` each resolve the seats file from their own `--env-file` with `SeatPool::ResolveSeatsFile` (`--env-file`, `$SEATS_ENV_FILE`, `<cwd>/.env` when it is a file holding a seat or a placement setting, each with a value (a blank line is neither), or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application `.env`, or one that is no file, is passed over, recorded as `passedOver` and logged by the run - the machine's own), reading each file once (`SeatsFile::Text()`: a pipe or a FIFO `--env-file` gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment, `SeatPool::WithFileTokens` - the one place, so `LoadSeats` takes the merged environment and reads no file). `SeatPool::SettingsOf` lays the file's placement settings (`Seats::SettingsFromEnvFile`, one dotenv grammar with the tokens', `DotenvLines`; `SeatPool::PlacementSettings`, the fifteen names the readers read, also deciding what makes `<cwd>/.env` a seats file - a test pins that each moves a reader) under the process environment into a map handed to the placement readers alone - `ApiConfig`, `PoolConfig`, `Seats::ConfigFromEnv` - names its other `REVIEW_GATES_*` lines (the run logs them, `seats` prints them on stderr), and the process environment is not touched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one `.env` keep one board and one ledger (the Python driver's `config_env` of 2026-10-17; the first self-review's rounds 3 to 10, 2026-11-01, reworked it as an application to the process environment until round 11 found the map the readers already took) (SeatPool).
- An account whose 5-hour limit Anthropic has removed reports the weekly window alone. Its burst window (`Seats::BurstWindow`) is the weekly one: it is ranked and admitted by the weekly window with the passes in flight projected on it (0.04 points per pass at xhigh, 0.125 otherwise), under the 99 % cutoff.
- A pass's projected share of the windows follows the run's effort: 0.5 / 0.125 points of the 5-hour window and 0.125 / 0.04 of the weekly one at max / xhigh (`SeatPool::LensCostPct`, `LensCost7dPct`), the pooled means of 5438 passes on 141 seat-runs (2026-10-12..15) rounded up. The weekly cutoff is 99 %: the running passes are projected against it, so the last percent is spent a pass at a time.
- Every OAuth account and the local login takes at most 128 passes at once, every driver's counted.
@ -119,6 +119,7 @@ Conventions:
- A 'concurrents' rejection pauses the seat for 20 s or its retry-after, not until a window's reset.
- Board entries and the folded `foreign` record carry `cost7d`, and so does the dump's seat summary.
- The `seats` report and the log table say `no limit` in such an account's 5-hour column.
- **The ledger's cycles and credit-outs** (SeatPool, Seats, Drivers): an API seat's cycle starts on its grant day, `ANTHROPIC_API_KEY<n>_GRANT_DAY` (`Seats::ApiGrantDays`; the seat token reader takes the line), else `REVIEW_GATES_API_CYCLE_DAY`, the day every OpenRouter key's starts on; `LoadSeats` puts an API seat's day in the seat and the ledger (`Ledger::SetDay`). A new cycle starts the spend over (`Ledger::Entry`) and carries a credit-out, with the time the key was last seen answering again. `Probe` re-probes a paid key the ledger holds out on credit (`ledgerOut` `credit`; one out on its budget it skips) - an API key with a request, an OpenRouter key through `ProbeOpenRouter` - and `seats` every API key; `Ledger::Reconcile` has the ledger follow an ok or credit-error reading in one locked update on the file's entry: an ok on a key out since before the probe started lifts the mark - an API key's is new credits (`Ledger::GrantEntry`: a cycle from the grant day when that is within the week, else from today), an OpenRouter key keeps its cycle's spend - a credit error on a key the file has as fine, with no lift seen since the probe started, is recorded, and anything else writes nothing. A mark is local time with its UTC offset (`2026-10-25T02:00:05+01:00`) and marks are ordered by instant, across the hour that repeats when DST ends and between drivers in different zones; a stamp without an offset, as older drivers wrote, is read as local time. `seats` stamps what it writes after its probe, not at the start its probes share. An ok reading over a credit error recorded since the probe started leaves the seat out, and a key that answers with its cycle's budget spent is out on that budget. `seats` shows the probe's reading beside the driver's verdict (`LedgerOut`, the one `LoadSeats` holds a seat out by), the grant day and the next credits (`NextGrant`).
- **Board identity and liveness, process lifetime, stop:** see "Inside the Flatpak".
- **Edge cases:**
- Usage errors of `run`, `freeze`, `slots`, `config` and the other commands exit 3, the preflight's code, and so does an INVARIANT failure of `run` and `slots`. The helpers exit 64.
@ -180,6 +181,7 @@ What the binary reads at run time, and how. A variable the driver only sets or s
|---|---|---|---|---|
| `CLAUDE_CODE_OAUTH_TOKEN1`..`6` (+ legacy `CLAUDE_CODE_OAUTH_TOKEN`) | `:Seats` SeatTokensFromEnv, via `:SeatPool` LoadSeats (run) and `seats` | none | The fixed names 1..6 only; `TOKEN7` does nothing. Stripped; an empty value is no seat. The bare name is seat 1 when `TOKEN1` is empty. The env file's lines override the process environment. Stripped from every child's environment. | OAuth seats `1`..`6`; a token reaches claude only as its seat's `CLAUDE_CONFIG_DIR/.credentials.json` |
| `ANTHROPIC_API_KEY1`..`6` | `:Seats` ApiKeysFromEnv (run, seats) | none | The fixed names 1..6, stripped; empty is no seat. The bare `ANTHROPIC_API_KEY` is never a seat (the env-file parser wants the digit too) and is stripped from every child. | API-credit seats `api1`..`api6`, each key in its own process's `ANTHROPIC_API_KEY` only |
| `ANTHROPIC_API_KEY1_GRANT_DAY`..`6` | `:Seats` ApiGrantDays, via LoadSeats (run) and `seats` | `REVIEW_GATES_API_CYCLE_DAY` | Stripped decimal digits of any script, 1..31; anything else is ignored. The env file's lines override the process environment. | The day of the month seat n's ledger cycle starts on |
| `OPENROUTER_KEY`, `OPENROUTER_KEY1`..`6` | `:Seats` OpenRouterKeysFromEnv, via LoadSeats (run; `seats` ignores them) | none | As the OAuth tokens; the bare name is `or1` when `KEY1` is empty. Never in `auto`: `--seats openrouter` or `orN` ids; mixed with other seats it is a preflight error (exit 3). | OpenRouter seats `or1`..`or6` |
| `SEATS_ENV_FILE` | `:SeatPool` ResolveSeatsFile (run's Gate 0, `seats`) | `<cwd>/.env` if it is a file holding a seat or a placement setting, each with a value (or no UTF-8 text: its error is the command's), else the machine's `$XDG_CONFIG_HOME/crafter-review/env` (`~/.config/crafter-review/env`) | `--env-file` wins (in run and `seats` even `--env-file ""`, meaning no file); empty counts as unset. Seat lines are taken (`[export ]NAME=value`, one pair of matching quotes stripped), overriding the process environment; the file's placement settings (`SeatPool::PlacementSettings`) go, under the process environment, into the map the placement readers take (`SeatPool::SettingsOf`, from the one read the tokens come from too), so every placement knob below may come from the file, the process environment winning; no other reader sees a line of it, and its other `REVIEW_GATES_*` lines are named (the run's log, `seats` on stderr). A file that cannot be read, or is not UTF-8: run stops at preflight (exit 3), `seats` exits 1 with the read error. | The dotenv file the seat secrets come from |
| `XDG_CONFIG_HOME` | `:SeatPool` GlobalEnvFile | `~/.config` | The directory whose `crafter-review/env` is the machine's own seats file, the last place a run or `seats` looks (2026-11-01). Runs, Commands and KernelModes, the tests that run `run` and `seats`, point it (and `HOST_XDG_CONFIG_HOME`) at a path nothing creates, so a global file on the machine stays out of them. |
@ -195,7 +197,7 @@ What the binary reads at run time, and how. A variable the driver only sets or s
| `REVIEW_GATES_API_BUDGET_USD` | `:SeatPool` ApiConfig (run, seats) | `200.0` | MAX_UTIL's syntax, kept if >= 0 (`inf` means no cap); empty, garbage, negative, `nan` give the default. `--api-budget` overrides it in run. | USD one API key may spend per cycle; at it the key is out |
| `REVIEW_GATES_API_LENS_COST_USD` | `:SeatPool` ApiConfig | `1.0` | As API_BUDGET_USD | USD reserved per running process: a pass fits while spent + (running+1) × this + other drivers' cost ≤ budget |
| `REVIEW_GATES_API_CONCURRENCY` | `:SeatPool` ApiConfig | `8` | As API_BUDGET_USD, truncated toward zero; `0` keeps every pass off the API seats; `inf` exits 1 with an OverflowError. | Processes at once on one API seat, every driver's counted |
| `REVIEW_GATES_API_CYCLE_DAY` | `:SeatPool` ApiConfig | `1` | As API_CONCURRENCY, then clamped to 1..28. | The day of the month the ledger's spend resets |
| `REVIEW_GATES_API_CYCLE_DAY` | `:SeatPool` ApiConfig | `1` | As API_CONCURRENCY, then clamped to 1..31; a day past a month's end is its last day. | The grant day of an API seat without its own `_GRANT_DAY`, and the day the driver's spend on every OpenRouter key starts over |
| `REVIEW_GATES_OPENROUTER_BUDGET_USD` | `:SeatPool` ApiConfig | `100.0` | As API_BUDGET_USD | USD per cycle per OpenRouter key |
| `REVIEW_GATES_OPENROUTER_LENS_COST_USD` | `:SeatPool` ApiConfig | `0.1` | As API_BUDGET_USD | Reserve per process on an OpenRouter seat |
| `REVIEW_GATES_OPENROUTER_CONCURRENCY` | `:SeatPool` ApiConfig | `16` | As API_CONCURRENCY | Processes at once on one OpenRouter seat |

View file

@ -23,7 +23,7 @@ The machine provides the rest:
| | |
|---|---|
| **The Claude Code CLI** | Proprietary, not bundled. Found through `CLAUDE_BIN` or `REVIEW_GATES_CLAUDE_BIN`, the PATH, an editor extension's bundled binary, `~/.claude/local` or `~/.local/bin`. |
| **Seats** | A dotenv file with `CLAUDE_CODE_OAUTH_TOKEN1..6` or `ANTHROPIC_API_KEY1..6`: `--env-file`, else `$SEATS_ENV_FILE`, else the working directory's `.env` when it holds a seat or a placement setting, else `~/.config/crafter-review/env`. Its seats go over the environment's; with none, your own login. The file's `REVIEW_GATES_*` placement settings count too. `crafter-review seats` probes them; every driver on the machine shares one seat board. |
| **Seats** | A dotenv file with `CLAUDE_CODE_OAUTH_TOKEN1..6` or `ANTHROPIC_API_KEY1..6`: `--env-file`, else `$SEATS_ENV_FILE`, else the working directory's `.env` when it holds a seat or a placement setting, else `~/.config/crafter-review/env`. Its seats go over the environment's; with none, your own login. The file's `REVIEW_GATES_*` placement settings count too; `ANTHROPIC_API_KEY<n>_GRANT_DAY` sets the day an API seat's cycle starts. `crafter-review seats` probes them; every driver on the machine shares one seat board. |
| **Chromium** | For a lens with the browser: `crafter-review browser install`, once. |
| **A local Supabase stack** | For a lens with the database (`supabase start`, or the repository's own bring-up). The `sql` helper speaks the Postgres protocol itself; every call is one transaction, rolled back. |
| **gpg** | Only for signed commits: `freeze` reads the signatures through gnupg's keyboxd socket. |
@ -41,7 +41,7 @@ crafter-review config # edit .review-gates/config.json until it prints no
`.review-gates/config.json` is committed. It says:
- what `freeze` checks: `authors`, `aiTrailers`, `requireSigned`, and the branch a range is measured against (`base`, `rangeFrom`);
- what `freeze` checks: `authors` (null: not checked), `aiTrailers`, `requireSigned`, and the branch a range is measured against (`base`, `rangeFrom`);
- `modes.<name>`: the kind of artifact, the lenses it runs and their effort. Every lens is off until a mode lists it; `run --mode` picks one;
- `lenses.<name>`: the environment a lens gets (`none`, `browser`, `supabase` or `browser+supabase`) and, if the repository has one, its `section` - a file of this repository's own appended to the lens's charter;
- `functional`, `slots`, `supabase`: how the application is started and reached, one stack or several, and where its database answers;

View file

@ -9,7 +9,6 @@ import :Core;
import :Plan;
import :Tools;
import :PgWire;
import :Process;
import :Platform;
import :Prompt;
import std;
@ -151,8 +150,8 @@ namespace Crafter::Review::Config {
// when HEAD tracks nothing; base: always merge-base HEAD
// <base>..HEAD, the whole pull request.
{"rangeFrom", "upstream"},
// Author and committer emails the freeze accepts; null: the
// repository's `git config user.email`.
// Author and committer emails the freeze accepts; null: neither
// is checked.
{"authors", nullptr},
// forbid | allow: Assisted-by / Co-Authored-By trailers in the
// range.
@ -266,6 +265,8 @@ namespace Crafter::Review::Config {
return a.is_string() && a.get_ref<const std::string&>().contains('@');
}))) {
out.emplace_back("authors must be null or a list of email addresses");
} else if (authors.is_array() && authors.empty()) {
out.emplace_back("authors is an empty list, which no commit passes - null leaves author and committer unchecked");
}
for (const std::string_view key : {"allowDirty", "gate0"}) {
if (!AllStrings(Dyn::At(config, key))) out.push_back(std::format("{} must be a list of strings", key));
@ -453,21 +454,12 @@ namespace Crafter::Review::Config {
return copy;
}
std::string GitUserEmail(const fs::path& repo) {
const Process::Captured run = Process::Run({"git", "-C", repo.string(), "config", "user.email"});
return run.returnCode == 0 ? Dyn::Strip(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out))) : std::string();
}
std::vector<std::string> Authors(const Json& config, const fs::path& repo) {
std::optional<std::vector<std::string>> Authors(const Json& config) {
const Json& authors = Dyn::Get(config, "authors");
if (Dyn::Truthy(authors)) {
std::vector<std::string> out;
for (const Json& author : authors) out.push_back(Dyn::Str(author));
return out;
}
const std::string email = GitUserEmail(repo);
if (email.empty()) return {};
return {email};
if (!authors.is_array()) return std::nullopt;
std::vector<std::string> out;
for (const Json& author : authors) out.push_back(Dyn::Str(author));
return out;
}
std::map<std::string, fs::path> LensSectionsOf(const Json& config, const fs::path& repo) {

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.

View file

@ -211,20 +211,18 @@ namespace Crafter::Review::Freeze {
fail(std::format("{} is not the toplevel of a git checkout", repo));
return report;
}
std::vector<std::string> authors = options.authors;
if (authors.empty()) authors = Config::Authors({{"authors", nullptr}}, repo);
if (authors.empty()) {
fail("no author identity: set `authors` in .review-gates/config.json or git config user.email");
return report;
}
const std::optional<std::vector<std::string>>& authors = options.authors;
const Base base = ResolveBase(repo, options.base, options.configBase, options.rangeFrom);
if (!base.failure.empty()) {
fail(base.failure);
return report;
}
const std::string head = Artifact::TreeHead(repo);
Json authorList = Json::array();
for (const std::string& author : authors) authorList.push_back(author);
Json authorList;
if (authors) {
authorList = Json::array();
for (const std::string& author : *authors) authorList.push_back(author);
}
report["base"] = base.sha;
report["baseFrom"] = base.how;
report["upstream"] = base.upstream ? Json(*base.upstream) : Json();
@ -261,11 +259,12 @@ namespace Crafter::Review::Freeze {
commitList.push_back({{"sha", c.sha}, {"subject", c.subject}, {"author", c.author}, {"committer", c.committer}, {"sig", c.sig}});
}
report["commits"] = commitList;
const std::string authorText = Dyn::Join(authors, ", ");
if (!authors) warn("no authors in .review-gates/config.json: author and committer not checked");
const std::string authorText = authors ? Dyn::Join(*authors, ", ") : std::string();
for (const Commit& c : commits) {
const std::string who = std::format("{} {}", Head12(c.sha), Dyn::Repr(Dyn::Slice(c.subject, 0, 50)));
if (!std::ranges::contains(authors, c.author)) fail(std::format("{}: author {} not in {}", who, c.author, authorText));
if (!std::ranges::contains(authors, c.committer)) fail(std::format("{}: committer {} not in {}", who, c.committer, authorText));
if (authors && !std::ranges::contains(*authors, c.author)) fail(std::format("{}: author {} not in {}", who, c.author, authorText));
if (authors && !std::ranges::contains(*authors, c.committer)) fail(std::format("{}: committer {} not in {}", who, c.committer, authorText));
if (const std::optional<std::string> trailer = AiTrailer(c.body)) {
const bool forbid = options.aiTrailers == "forbid";
const std::string message = std::format("{}: AI attribution trailer {}{}", who, Dyn::Repr(Dyn::Strip(*trailer)),
@ -472,7 +471,7 @@ namespace Crafter::Review::Freeze {
options.replace = arguments.replace;
options.worktree = arguments.worktree;
options.requireSigned = arguments.requireSigned || Dyn::Truthy(Dyn::Get(config, "requireSigned"));
options.authors = Config::Authors(config, repo);
options.authors = Config::Authors(config);
options.aiTrailers = Dyn::Str(Dyn::Get(config, "aiTrailers"));
options.allowDirty = allowDirty;
const Json& configBase = Dyn::Get(config, "base");

View file

@ -767,7 +767,7 @@ namespace Crafter::Review::Run {
std::ranges::sort(sorted);
Die(std::format("OpenRouter seats need OpenRouter model ids (vendor/model): --models opus=...,fable=... (now {})", Dyn::Join(sorted, ", ")));
}
if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log);
if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log, {}, ledger.get());
if (board) {
// Our readings onto the board (a probed-ok seat clears a verdict
// another driver left), theirs into our seats.
@ -793,7 +793,12 @@ namespace Crafter::Review::Run {
for (const Json& s : seats) names.push_back(std::format("{} ({})", Dyn::Str(s.at("id")), Dyn::Str(s.at("kind"))));
std::string line = std::format("seats: {} from {}", Dyn::Join(names, ", "), envFile && !envFile->empty() ? *envFile : std::string("this machine"));
if (std::ranges::any_of(seats, [](const Json& s) { return s.at("kind") == "api"; })) {
line += std::format("; {}, budget ${:g} per api seat per cycle (ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), ledger->Path().string());
std::vector<std::string> days;
for (const Json& s : seats) {
if (s.at("kind") == "api") days.push_back(std::format("{} {}", Dyn::Str(s.at("id")), Dyn::Str(Dyn::Get(s, "grantDay"))));
}
line += std::format("; {}, budget ${:g} per api seat per cycle from its grant day ({}; ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), Dyn::Join(days, ", "),
ledger->Path().string());
}
if (openrouter) {
line += std::format("; budget ${:g} per OpenRouter seat per cycle, {} concurrent (ledger {})", Number(apiCfg.at("orBudgetUsd")), Dyn::Str(apiCfg.at("orConcurrency")), ledger->Path().string());

View file

@ -206,7 +206,8 @@ namespace Crafter::Review::SeatPool {
{"budgetUsd", num("REVIEW_GATES_API_BUDGET_USD", 200.0)},
{"lensCostUsd", num("REVIEW_GATES_API_LENS_COST_USD", 1.0)},
{"concurrency", Int(num("REVIEW_GATES_API_CONCURRENCY", 8))},
{"cycleDay", std::max<std::int64_t>(1, std::min<std::int64_t>(28, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))},
// A day past a month's end is its last day (CycleStart).
{"cycleDay", std::max<std::int64_t>(1, std::min<std::int64_t>(31, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))},
{"order", EnvOf(env, "REVIEW_GATES_SEAT_ORDER") == "oauth-first" ? "oauth-first" : "api-first"},
// OpenRouter seats: a prepaid key, cheap models - a smaller reserve
// per running process.
@ -233,47 +234,153 @@ namespace Crafter::Review::SeatPool {
return root / "seat-ledger.json";
}
namespace {
std::chrono::year_month_day LocalDate(double now) {
using namespace std::chrono;
const std::string today = Platform::LocalTime("%Y-%m-%d", now);
return {year(std::stoi(today.substr(0, 4))), month(static_cast<unsigned>(std::stoi(today.substr(5, 2)))), day(static_cast<unsigned>(std::stoi(today.substr(8, 2))))};
}
std::chrono::year_month_day ParseIso(std::string_view iso) {
using namespace std::chrono;
const std::string text(iso);
return {year(std::stoi(text.substr(0, 4))), month(static_cast<unsigned>(std::stoi(text.substr(5, 2)))), day(static_cast<unsigned>(std::stoi(text.substr(8, 2))))};
}
std::string Iso(const std::chrono::year_month_day& d) {
return std::format("{:04d}-{:02d}-{:02d}", static_cast<std::int32_t>(d.year()), static_cast<std::uint32_t>(d.month()), static_cast<std::uint32_t>(d.day()));
}
// A ledger stamp: local time with its UTC offset,
// 2026-10-25T02:00:05+01:00, so any driver orders it by instant across
// the hour that repeats when DST ends, or from another zone.
std::string Stamp(double at) {
std::string text = Platform::LocalTime("%Y-%m-%dT%H:%M:%S%z", at);
if (text.size() == 24) text.insert(22, ":");
return text;
}
// A stamp's instant, epoch seconds: by its offset, else (a stamp
// written before 1.1.0) as local time; none when it cannot be read.
std::optional<double> Instant(std::string_view stamp) {
auto number = [&](std::size_t from, std::size_t width) -> std::optional<std::int32_t> {
if (from + width > stamp.size()) return std::nullopt;
std::int32_t value = 0;
for (std::size_t i = from; i < from + width; ++i) {
if (stamp[i] < '0' || stamp[i] > '9') return std::nullopt;
value = value * 10 + (stamp[i] - '0');
}
return value;
};
if (stamp.size() < 19 || stamp[4] != '-' || stamp[7] != '-' || stamp[10] != 'T' || stamp[13] != ':' || stamp[16] != ':') return std::nullopt;
const std::optional<std::int32_t> y = number(0, 4), mo = number(5, 2), d = number(8, 2), h = number(11, 2), mi = number(14, 2), s = number(17, 2);
if (!y || !mo || !d || !h || !mi || !s) return std::nullopt;
const double clock = *h * 3600.0 + *mi * 60.0 + *s;
if (stamp.size() == 25 && (stamp[19] == '+' || stamp[19] == '-') && stamp[22] == ':') {
const std::optional<std::int32_t> oh = number(20, 2), om = number(23, 2);
const std::chrono::year_month_day date {std::chrono::year(*y), std::chrono::month(static_cast<unsigned>(*mo)), std::chrono::day(static_cast<unsigned>(*d))};
if (!oh || !om || !date.ok()) return std::nullopt;
const double offset = (stamp[19] == '-' ? -1.0 : 1.0) * (*oh * 3600.0 + *om * 60.0);
return static_cast<double>(std::chrono::sys_days(date).time_since_epoch().count()) * 86400.0 + clock - offset;
}
if (stamp.size() != 19) return std::nullopt;
std::tm local {};
local.tm_year = *y - 1900;
local.tm_mon = *mo - 1;
local.tm_mday = *d;
local.tm_hour = *h;
local.tm_min = *mi;
local.tm_sec = *s;
local.tm_isdst = -1;
const std::time_t seconds = std::mktime(&local);
if (seconds == static_cast<std::time_t>(-1)) return std::nullopt;
return static_cast<double>(seconds);
}
// That day of the month, a day past its end its last day.
std::chrono::year_month_day OnDay(const std::chrono::year_month& ym, std::int32_t day) {
using namespace std::chrono;
const auto last = static_cast<std::int32_t>(static_cast<unsigned>(year_month_day_last(ym.year(), month_day_last(ym.month())).day()));
return {ym.year(), ym.month(), std::chrono::day(static_cast<unsigned>(std::clamp(day, 1, last)))};
}
}
std::string CycleStart(double now, std::int32_t day) {
using namespace std::chrono;
const std::string today = Platform::LocalTime("%Y-%m-%d", now);
const year_month_day date(year(std::stoi(today.substr(0, 4))), month(static_cast<unsigned>(std::stoi(today.substr(5, 2)))),
std::chrono::day(static_cast<unsigned>(std::stoi(today.substr(8, 2)))));
auto iso = [](const year_month_day& d) {
return std::format("{:04d}-{:02d}-{:02d}", static_cast<std::int32_t>(d.year()), static_cast<std::uint32_t>(d.month()), static_cast<std::uint32_t>(d.day()));
};
if (static_cast<std::int32_t>(static_cast<std::uint32_t>(date.day())) >= day) {
return iso(year_month_day(date.year(), date.month(), std::chrono::day(static_cast<std::uint32_t>(day))));
}
const year_month_day previous(sys_days(year_month_day(date.year(), date.month(), std::chrono::day(1))) - days(1));
const auto last = static_cast<std::int32_t>(static_cast<std::uint32_t>(previous.day()));
return iso(year_month_day(previous.year(), previous.month(), std::chrono::day(static_cast<std::uint32_t>(std::min(day, last)))));
const year_month_day date = LocalDate(now);
const year_month ym(date.year(), date.month());
const year_month_day thisMonth = OnDay(ym, day);
return Iso(date >= thisMonth ? thisMonth : OnDay(ym - months(1), day));
}
std::string NextGrant(double now, std::int32_t day, const Json& outSince) {
using namespace std::chrono;
const std::string cycle = CycleStart(now, day);
if (Dyn::Truthy(outSince) && Dyn::Slice(Dyn::Str(outSince), 0, 10) < cycle) return cycle;
const year_month_day start = ParseIso(cycle);
return Iso(OnDay(year_month(start.year(), start.month()) + months(1), day));
}
std::pair<bool, std::string> LedgerVerdict(const Json& entry, double budget) {
const Json& outSince = Dyn::Get(entry, "outSince");
if (Dyn::Truthy(outSince)) return {true, std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(entry, "outReason"), "out"), Dyn::Slice(Dyn::Str(outSince), 0, 16))};
const double spent = Num(Dyn::Get(entry, "spentUsd"));
if (spent >= budget) return {true, std::format("ledger: ${:.2f} spent this cycle, budget ${}", spent, G(budget))};
return {false, std::string()};
}
Json LedgerOut(const Json& entry, double budget) {
if (!LedgerVerdict(entry, budget).first) return nullptr;
return Dyn::Truthy(Dyn::Get(entry, "outSince")) ? "credit" : "budget";
}
// ---- the ledger -------------------------------------------------------------------
Ledger::Ledger(fs::path path, Json apiConfig, Json data) : path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()) {}
Ledger::Ledger(fs::path path, Json apiConfig, Json data, std::map<std::string, std::int32_t> days)
: path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()), days(std::move(days)) {}
Ledger Ledger::Load(const fs::path& path, const Json& apiConfig) {
Ledger Ledger::Load(const fs::path& path, const Json& apiConfig, std::map<std::string, std::int32_t> days) {
Json data;
try {
data = Dyn::LoadJsonFile(path);
} catch (const std::exception&) {
data = Json::object();
}
return Ledger(path, apiConfig, data.is_object() ? data : Json::object());
return Ledger(path, apiConfig, data.is_object() ? data : Json::object(), std::move(days));
}
std::int32_t Ledger::Day(const std::string& seatId) const {
const auto found = days.find(seatId);
if (found != days.end() && found->second > 0) return found->second;
return static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay"))));
}
void Ledger::SetDay(const std::string& seatId, std::int32_t day) {
days[seatId] = day;
}
Json& Ledger::Entry(const std::string& seatId, std::optional<double> now) {
const std::string cycle = CycleStart(NowOr(now), static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))));
const std::string cycle = CycleStart(NowOr(now), Day(seatId));
const Json& e = Dyn::Get(data, seatId);
// Only a newer cycle starts over: an entry of a later one (another
// driver's cycle day) is not wiped.
if (!Dyn::Truthy(e) || cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) {
if (!Dyn::Truthy(e)) {
data[seatId] = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}};
} else if (cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) {
// Only a newer cycle starts over: an entry of a later one (another
// driver's cycle day, or a grant seen early) is not wiped. A
// credit-out carries: the cycle's day is a configured date, the
// credit error was observed (2026-10-17: the calendar cycle held
// api3 out for the first week of its new credits; an OpenRouter
// key's turn puts nothing on it), and only a probe that sees the
// key answer again lifts it (Reconcile). When the key was last seen
// answering again carries too: Reconcile records no credit error
// read before it.
Json next = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", Dyn::Get(e, "outSince")}, {"outReason", Dyn::Get(e, "outReason")}};
if (Dyn::Truthy(Dyn::Get(e, "grantSeen"))) next["grantSeen"] = Dyn::Get(e, "grantSeen");
data[seatId] = std::move(next);
}
return data[seatId];
}
Json Ledger::Update(const std::string& seatId, const std::function<void(Json&)>& change, std::optional<double> now) {
Json Ledger::Update(const std::string& seatId, const std::function<bool(Json&)>& change, std::optional<double> now) {
// The file is the record: re-read under its lock, this process only
// ever adds to it (2026-10-15: each driver saved the copy it had loaded
// and the last writer won - api4 reached the credit error with $157.54
@ -285,10 +392,10 @@ namespace Crafter::Review::SeatPool {
for (auto it = file.begin(); it != file.end(); ++it) merged[it.key()] = it.value();
data = std::move(merged);
Json& e = Entry(seatId, now);
change(e);
const bool write = change(e);
entry = e;
file = data;
return true;
if (write) file = data;
return write;
}, data, 0600, {});
return entry;
}
@ -297,16 +404,70 @@ namespace Crafter::Review::SeatPool {
return Update(seatId, [&](Json& e) {
e["spentUsd"] = Dyn::Round(AsDouble(Dyn::At(e, "spentUsd")) + usd, 4);
e["procs"] = Dyn::Add(Dyn::At(e, "procs"), Json(1));
return true;
}, now);
}
Json Ledger::MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now) {
return Update(seatId, [&](Json& e) {
e["outSince"] = Platform::LocalTime("%Y-%m-%dT%H:%M:%S", NowOr(now));
e["outSince"] = Stamp(NowOr(now));
e["outReason"] = Dyn::Slice(reason, 0, 160);
return true;
}, now);
}
Json Ledger::GrantEntry(const std::string& seatId, double at) const {
using namespace std::chrono;
const std::string cycle = CycleStart(at, Day(seatId));
const std::string weekAgo = Iso(year_month_day(sys_days(LocalDate(at)) - std::chrono::days(7)));
const std::string start = cycle >= weekAgo ? cycle : Iso(LocalDate(at));
return {{"cycle", start}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}, {"grantSeen", Stamp(at)}};
}
std::string Ledger::Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional<double> now) {
if (state != "ok" && state != "out") return {};
const double at = NowOr(now);
// A mark made before the probe started, by instant to the second the
// stamps hold; one made since may postdate what the probe read, and
// one that cannot be read counts as made since.
const double started = std::floor(probedAt);
auto before = [&](const Json& stamp) {
const std::optional<double> instant = Instant(Dyn::Str(stamp));
return instant && *instant < started;
};
const Json fresh = GrantEntry(seatId, at);
std::string event;
// Decided on the file's entry under the lock, not on this process's
// copy: another driver may have recorded the grant and spent on it,
// or the credit error, since this copy was read.
// An OpenRouter key (or<n>) is prepaid: its lift is no grant, no new
// cycle - the mark goes, the cycle's spend stays.
const bool openRouter = seatId.starts_with("or");
Update(seatId, [&](Json& e) {
const Json& outSince = Dyn::Get(e, "outSince");
if (state == "ok") {
if (!Dyn::Truthy(outSince) || !before(outSince)) return false;
if (openRouter) {
e["outSince"] = nullptr;
e["outReason"] = nullptr;
e["grantSeen"] = Stamp(at);
event = "credit-back";
} else {
e = fresh;
event = "new-grant";
}
return true;
}
const Json& seen = Dyn::Get(e, "grantSeen");
if (Dyn::Truthy(outSince) || (Dyn::Truthy(seen) && !before(seen))) return false;
e["outSince"] = Stamp(at);
e["outReason"] = openRouter ? "key limit reached" : "credit balance too low";
event = "credit-gone";
return true;
}, at);
return event;
}
void Ledger::Save() {
try {
Platform::WriteAtomic(path, DumpFile(data), 0600);
@ -319,6 +480,10 @@ namespace Crafter::Review::SeatPool {
return data;
}
const Json& Ledger::ApiConfig() const {
return apiConfig;
}
const fs::path& Ledger::Path() const {
return path;
}
@ -436,7 +601,7 @@ namespace Crafter::Review::SeatPool {
return env;
}
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view specText, Ledger* ledger, const Json& apiConfig) {
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view specText, Ledger* ledger, const Json& apiConfig, std::optional<double> now) {
const std::map<std::string, std::string>& merged = env;
std::vector<std::pair<std::string, Json>> pool;
for (const auto& [n, key] : Seats::ApiKeysFromEnv(merged)) pool.emplace_back(std::format("api{}", n), NewSeat(std::format("api{}", n), "api", key));
@ -482,18 +647,31 @@ namespace Crafter::Review::SeatPool {
if (std::ranges::any_of(seats, IsOpenRouter) && !std::ranges::all_of(seats, IsOpenRouter)) {
throw std::invalid_argument("--seats: an OpenRouter seat cannot share a pool with Anthropic seats or the local login - a model name means a different model per endpoint");
}
// An API seat's cycle starts on its grant day (2026-10-17): the day
// its organization receives the plan's credits, the Console's
// Promotional-credits expiry minus three days.
const std::map<std::int32_t, std::int32_t> grantDays = Seats::ApiGrantDays(env);
for (Json& s : seats) {
if (Kind(s) != "api") continue;
const auto found = grantDays.find(static_cast<std::int32_t>(Dyn::ParseInt(Dyn::Slice(Id(s), 3)).value_or(0)));
const std::int32_t day = found != grantDays.end() ? found->second : (Dyn::Truthy(apiConfig) ? static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))) : 1);
s["grantDay"] = day;
if (ledger) ledger->SetDay(Id(s), day);
}
if (ledger) {
const double now = Platform::Now();
const double at = NowOr(now);
for (Json& s : seats) {
if (!IsPaid(s)) continue;
const Json& e = ledger->Entry(Id(s), now);
const Json& e = ledger->Entry(Id(s), at);
s["spentUsd"] = AsDouble(Dyn::At(e, "spentUsd"));
if (Dyn::Truthy(Dyn::Get(e, "outSince"))) {
const double budget = Dyn::Truthy(apiConfig) ? Budget(s, apiConfig) : std::numeric_limits<double>::infinity();
const auto [out, why] = LedgerVerdict(e, budget);
if (out) {
s["state"] = "out";
s["error"] = std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(e, "outReason"), "out"), Dyn::Slice(Dyn::Str(e.at("outSince")), 0, 16));
} else if (Dyn::Truthy(apiConfig) && AsDouble(s.at("spentUsd")) >= Budget(s, apiConfig)) {
s["state"] = "out";
s["error"] = std::format("ledger: ${:.2f} spent this cycle, budget ${}", AsDouble(s.at("spentUsd")), G(Budget(s, apiConfig)));
s["error"] = why;
// Why the ledger holds the seat out: a credit-out is
// probed again, a spent budget is not.
s["ledgerOut"] = LedgerOut(e, budget);
}
}
}
@ -541,7 +719,62 @@ namespace Crafter::Review::SeatPool {
return std::format("{}: {}{}", Dyn::Str(s.at("state")), Dyn::Join(parts, ", "), Dyn::Truthy(binding) ? std::format(" (binding {})", Dyn::Str(binding)) : std::string());
}
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models, const Log& log, const ProbeFunction& probe) {
namespace {
// A paid key after its probe (started at probedAt): the seat takes
// the spend the file holds, every driver's; a lift puts a held-out
// seat back (an API key's with its cycle restarted), a credit error
// recorded since the probe started stands over its answer, the credit
// error takes it out (an API key's with the ledger's record and the
// next credits' date in the reason), an unreadable probe leaves a
// held-out seat held out.
void AfterPaidProbe(Json& s, const Json& held, Ledger* ledger, double probedAt, double now, const Log& log) {
const std::string event = ledger ? ledger->Reconcile(Id(s), s.at("state"), probedAt, now) : std::string();
const Json entry = ledger ? ledger->Entry(Id(s), now) : Json::object();
if (ledger) s["spentUsd"] = Num(Dyn::Get(entry, "spentUsd"));
if (event == "new-grant") {
s["ledgerOut"] = nullptr;
s["error"] = nullptr;
log(std::format("seat {}: the key answers again - new credits; the ledger cycle restarts (from {})", Id(s), Dyn::Str(entry.at("cycle"))));
} else if (event == "credit-back") {
s["ledgerOut"] = nullptr;
s["error"] = nullptr;
log(std::format("seat {}: the key's limit has room again - back, its cycle's spend kept", Id(s)));
} else if (s.at("state") == "ok" && Dyn::Truthy(Dyn::Get(entry, "outSince"))) {
s["state"] = "out";
s["ledgerOut"] = "credit";
s["error"] = LedgerVerdict(entry, std::numeric_limits<double>::infinity()).second;
} else if (s.at("state") == "ok") {
s["ledgerOut"] = nullptr;
} else if (s.at("state") == "out" && IsOpenRouter(s)) {
// The probe's error says why.
s["ledgerOut"] = "credit";
} else if (s.at("state") == "out") {
const Json e = ledger ? ledger->Entry(Id(s), now) : Json::object();
const Json& outSince = Dyn::Get(e, "outSince");
const Json& day = Dyn::Get(s, "grantDay");
s["ledgerOut"] = "credit";
s["error"] = std::format("credit balance too low{}; next credits due {}", Dyn::Truthy(outSince) ? std::format(" since {}", Dyn::Slice(Dyn::Str(outSince), 0, 16)) : std::string(),
NextGrant(now, Dyn::Truthy(day) ? static_cast<std::int32_t>(Int(AsDouble(day))) : 1, outSince));
} else if (s.at("state") == "unknown" && Dyn::Truthy(held)) {
s["state"] = "out";
s["error"] = std::format("{} (probe unreadable: {})", Dyn::Str(held), Dyn::Str(Dyn::Get(s, "error")));
}
// A key that answers with its cycle's budget spent is out on that
// budget, as LoadSeats holds such a seat out: an OpenRouter key's
// lift keeps its spend, and another driver may have spent on a
// grant it saw. Left ok, the seat would be neither placed nor out.
if (ledger && s.at("state") == "ok") {
if (const auto [out, why] = LedgerVerdict(entry, Budget(s, ledger->ApiConfig())); out) {
s["state"] = "out";
s["ledgerOut"] = "budget";
s["error"] = why;
}
}
}
}
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models, const Log& log, const ProbeFunction& probe, Ledger* ledger,
std::optional<double> now, const OpenRouterProbeFunction& probeKey) {
const ProbeFunction run = probe ? probe : ProbeFunction([](std::int32_t seat, const std::string& token, const std::string& model, double t, const std::string& bin, bool local, bool api) {
return Seats::ProbeSeat(seat, token, {.model = model, .timeoutS = t, .claudeBin = bin, .local = local, .api = api});
});
@ -555,7 +788,7 @@ namespace Crafter::Review::SeatPool {
break;
}
}
if (std::ranges::any_of(seats, IsOpenRouter)) {
if (!models.empty() && std::ranges::any_of(seats, IsOpenRouter)) {
const Json prices = FetchOpenRouterPrices(models, log);
for (auto it = prices.begin(); it != prices.end(); ++it) {
const Json& p = it.value();
@ -569,22 +802,33 @@ namespace Crafter::Review::SeatPool {
}
const std::string lensModel = fable.value_or(std::string(Seats::ProbeModel));
for (Json& s : seats) {
if (s.at("state") == "out") continue;
const std::string kind = Kind(s);
if (s.at("state") == "out" && !(IsPaid(s) && Dyn::Get(s, "ledgerOut") == "credit")) continue;
const std::string id = Id(s);
const std::string secret = Dyn::StrOr(Dyn::Get(s, "secret"), "");
if (kind == "openrouter") {
const Json r = ProbeOpenRouter(secret, timeout);
// The ledger's verdict before the probe.
const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json();
const double probedAt = NowOr(now);
const Json r = probeKey ? probeKey(secret, timeout) : ProbeOpenRouter(secret, timeout);
ApplyProbe(s, r);
s["remainingUsd"] = Dyn::Get(r, "remaining");
AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log);
log(std::format("seat {} ({}): {}", id, kind, Describe(s)));
continue;
}
const std::int32_t n = kind == "api" ? static_cast<std::int32_t>(Dyn::ParseInt(Dyn::Slice(id, 3)).value_or(0)) : (kind == "local" ? 0 : static_cast<std::int32_t>(Dyn::ParseInt(id).value_or(0)));
Json r;
if (kind == "api") {
r = run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true);
} else if (kind == "local") {
// The ledger's verdict before the probe.
const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json();
const double probedAt = NowOr(now);
ApplyProbe(s, run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true));
AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log);
log(std::format("seat {} ({}): {}", id, kind, Describe(s)));
continue;
}
if (kind == "local") {
r = run(n, "", lensModel, timeout, claudeBin, true, false);
} else {
r = run(n, secret, lensModel, timeout, claudeBin, false, false);

View file

@ -298,6 +298,20 @@ namespace Crafter::Review::Seats {
return out;
}
std::map<std::int32_t, std::int32_t> ApiGrantDays(const std::map<std::string, std::string>& env) {
std::map<std::int32_t, std::int32_t> out;
for (std::int32_t i = 1; i <= MaxSeats; ++i) {
// Decimal digits of any script; past 31 the value stays out of
// range.
const std::u32string value = Dyn::Decode(Dyn::Strip(EnvOf(env, std::format("ANTHROPIC_API_KEY{}_GRANT_DAY", i))));
if (value.empty() || !std::ranges::all_of(value, [](char32_t c) { return Dyn::DecimalValue(c) >= 0; })) continue;
std::int32_t day = 0;
for (const char32_t c : value) day = std::min(day * 10 + Dyn::DecimalValue(c), 32);
if (day >= 1 && day <= 31) out[i] = day;
}
return out;
}
std::vector<std::pair<std::int32_t, std::string>> OpenRouterKeysFromEnv(const std::map<std::string, std::string>& env) {
std::vector<std::pair<std::int32_t, std::string>> out;
for (std::int32_t i = 1; i <= MaxSeats; ++i) {
@ -341,7 +355,9 @@ namespace Crafter::Review::Seats {
for (const auto& [name, value] : DotenvLines(text)) {
for (const std::string_view stem : {std::string_view("CLAUDE_CODE_OAUTH_TOKEN"), std::string_view("ANTHROPIC_API_KEY"), std::string_view("OPENROUTER_KEY")}) {
if (!name.starts_with(stem)) continue;
const std::string_view rest = std::string_view(name).substr(stem.size());
std::string_view rest = std::string_view(name).substr(stem.size());
// ANTHROPIC_API_KEY<n>_GRANT_DAY: the seat's grant day.
if (stem == "ANTHROPIC_API_KEY" && rest.size() == 11 && rest.ends_with("_GRANT_DAY")) rest = rest.substr(0, 1);
const bool digit = rest.size() == 1 && rest[0] >= '1' && rest[0] <= '6';
if (!rest.empty() && !digit) break;
if (stem == "ANTHROPIC_API_KEY" && !digit) break;
@ -980,26 +996,54 @@ namespace Crafter::Review::Seats {
return Dyn::Join(lines, "\n");
}
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, const Json& board, const std::map<std::int32_t, std::string>& keys) {
std::vector<std::vector<std::string>> rows = {{"seat", "state", "driver spend this cycle", "procs", "cycle from", "ledger: out", "in flight"}};
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, double now, const Json& board, const std::map<std::int32_t, std::string>& keys) {
const std::string today = Platform::LocalTime("%Y-%m-%d", now);
// "17 Sept"
auto date = [](const std::string& iso) {
return std::format("{} {}", std::stoi(iso.substr(8, 2)), Months[static_cast<std::size_t>(std::stoi(iso.substr(5, 2)) - 1)]);
};
std::vector<std::vector<std::string>> rows = {{"seat", "probe", "driver", "driver spend this cycle", "procs", "cycle from", "grant day", "next credits", "in flight"}};
std::vector<std::string> notes;
for (const Json& s : SortedBySeat(apiSeats)) {
const std::string id = std::format("api{}", Dyn::Str(Dyn::At(s, "seat")));
const Json& ledger = Dyn::Or(Dyn::Get(s, "ledger"), Dyn::EmptyDict());
const double spent = NumberOr0(Dyn::Get(ledger, "spentUsd"));
const std::string state = Dyn::Str(Dyn::At(s, "state")) + (Dyn::Truthy(Dyn::Get(s, "error")) && Dyn::At(s, "state") != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string());
const Json& outSince = Dyn::Get(ledger, "outSince");
const std::string state = Dyn::Str(Dyn::At(s, "state"));
const std::string probe = state + (Dyn::Truthy(Dyn::Get(s, "error")) && state != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string());
// The driver's verdict, SeatPool::LedgerOut's.
const Json& ledgerOut = Dyn::Get(s, "ledgerOut");
const bool out = !ledgerOut.is_null();
const std::string driver = !out ? std::string("ok")
: ledgerOut == "credit" ? std::format("out (credit since {})", Dyn::Replace(Dyn::Slice(Dyn::Str(Dyn::Get(ledger, "outSince")), 0, 16), "T", " "))
: std::string("out (budget spent)");
const std::string due = Dyn::StrOr(Dyn::Get(s, "nextCredits"), "");
const std::string dueText = due.size() < 10 ? std::string("—") : due == today ? std::string("today") : due < today ? std::format("overdue, {}", date(due)) : date(due);
const std::int64_t n = Inflight(board, keys, s);
rows.push_back({
std::format("api{}", Dyn::Str(Dyn::At(s, "seat"))), state, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"),
Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::Truthy(outSince) ? std::format("since {}", Dyn::Replace(Dyn::Slice(Dyn::Str(outSince), 0, 16), "T", " ")) : std::string("—"),
n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—"),
});
rows.push_back({id, probe, driver, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"),
Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::StrOr(Dyn::Get(s, "grantDay"), "—"), dueText,
n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—")});
const Json& event = Dyn::Get(s, "event");
if (event == "new-grant") {
notes.push_back(std::format("{}: the key answers again - new credits; the ledger cycle restarted (from {})", id, Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—")));
} else if (event == "credit-gone") {
notes.push_back(std::format("{}: the credit error, now recorded in the ledger", id));
} else if (state == "ok" && ledgerOut == "credit") {
notes.push_back(std::format("{}: the key answered, but the credit error was recorded since the probe started - the ledger's verdict stands", id));
} else if (state == "ok" && out) {
notes.push_back(std::format("{}: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)", id));
} else if (state != "ok" && state != "out" && out) {
notes.push_back(std::format("{}: probe {} - the ledger's verdict stands", id, state));
}
}
return Dyn::Join(std::vector<std::string>{
RenderTable(rows),
std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · cycle day {} · {} processes per seat · placed at +${} each",
Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))),
"the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent",
}, "\n");
std::vector<std::string> lines = {RenderTable(rows)};
lines.insert(lines.end(), notes.begin(), notes.end());
lines.push_back(std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} 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={}) · {} processes per seat · placed at +${} each",
Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")),
Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))));
lines.emplace_back("the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent, and \"overdue\" means the grant day "
"is wrong");
return Dyn::Join(lines, "\n");
}
std::string RenderDrivers(const Json& board, double now, const Zone& zone) {

View file

@ -36,10 +36,9 @@ export namespace Crafter::Review::Config {
// The config for a dump: the database URL with its password masked, no
// `_` bookkeeping keys.
Json Redacted(const Json& config);
// The emails the freeze accepts: the configured list, else the
// repository's git identity.
std::vector<std::string> Authors(const Json& config, const std::filesystem::path& repo);
std::string GitUserEmail(const std::filesystem::path& repo);
// The emails the freeze accepts as author and committer: the configured
// list; none (null, or no `authors` key) - the freeze checks neither.
std::optional<std::vector<std::string>> Authors(const Json& config);
// The lens files no mode lists (what runs nowhere: the slots and init).
std::vector<std::string> LensesOff(const Json& config);
// {lens file: environment} for every lens of the table, as the config

View file

@ -56,7 +56,8 @@ export namespace Crafter::Review::Freeze {
bool replace = false;
bool worktree = false;
bool requireSigned = false;
std::vector<std::string> authors;
// The emails author and committer must be among; none: not checked.
std::optional<std::vector<std::string>> authors;
std::string aiTrailers = "forbid";
std::vector<std::string> allowDirty;
std::optional<std::string> configBase;

View file

@ -133,28 +133,66 @@ export namespace Crafter::Review::SeatPool {
// 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.
// The most recent date with that day of the month, as YYYY-MM-DD; a day
// past a month's end is its last day.
std::string CycleStart(double now, std::int32_t day);
// When a seat's next credits are due, as YYYY-MM-DD: the grant day after
// the current cycle's, or the current cycle's own when the seat went out
// on credit before it (outSince) and is still out - due today, or overdue:
// the grant day is wrong.
std::string NextGrant(double now, std::int32_t day, const Json& outSince = nullptr);
// The driver's verdict on a paid seat from its ledger entry alone: out
// with the reason, or ok with none.
std::pair<bool, std::string> LedgerVerdict(const Json& entry, double budget);
// Why that verdict holds a seat out: "credit" (the key answered the
// credit error), "budget", or null when it does not.
Json LedgerOut(const Json& entry, double budget);
// 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.
// applied under its lock. A seat's cycle starts on its grant day (days,
// else the config's cycleDay).
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);
Ledger(std::filesystem::path path, Json apiConfig, Json data = Json::object(), std::map<std::string, std::int32_t> days = {});
static Ledger Load(const std::filesystem::path& path, const Json& apiConfig, std::map<std::string, std::int32_t> days = {});
std::int32_t Day(const std::string& seatId) const;
void SetDay(const std::string& seatId, std::int32_t day);
// The seat's entry of the current cycle. A new cycle starts the spend
// over; a credit-out carries over it - only a probe that sees the key
// answer again lifts it (Reconcile) - with the time the key was last
// seen answering again. This process's copy: only an Update
// re-reads the file.
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);
// The probe's reading of an API key (probedAt: when the probe
// started), and the ledger follows it, decided under the lock on the
// entry as the file has it: "ok" on a key held out on credit since
// before the probe started lifts the mark - an API key's is new
// credits, GrantEntry's entry, "new-grant"; an OpenRouter key's keeps
// its cycle's spend, "credit-back" -; the credit error on a key the file
// has as fine, no grant seen since the probe started, is recorded,
// "credit-gone". Anything else writes nothing, "": a mark another
// driver made after the probe started may postdate its reading, and
// a grant another driver already recorded keeps its spend.
std::string Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional<double> now = std::nullopt);
void Save();
const Json& Data() const;
const std::filesystem::path& Path() const;
// The config the ledger was loaded with (ApiConfig's).
const Json& ApiConfig() 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);
std::map<std::string, std::int32_t> days;
// change returns whether to write the file.
Json Update(const std::string& seatId, const std::function<bool(Json&)>& change, std::optional<double> now);
// A grant's entry: the cycle from the seat's grant day when that is
// within the week, else from today; no spend, no out mark.
Json GrantEntry(const std::string& seatId, double at) const;
};
Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret);
@ -163,12 +201,23 @@ export namespace Crafter::Review::SeatPool {
// 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, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr);
// mixed with the rest. An API seat gets its grantDay
// (ANTHROPIC_API_KEY<n>_GRANT_DAY of env, else the cycleDay) into the
// seat and the ledger; a seat the ledger holds out is out, ledgerOut
// "credit" or "budget".
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr,
std::optional<double> now = std::nullopt);
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.
// An OpenRouter key's probe, ProbeOpenRouter's shape.
using OpenRouterProbeFunction = std::function<Json(const std::string& secret, double timeout)>;
// One probe per seat before the run; a seat already out is skipped,
// except a paid key the ledger holds out on credit: probed again
// (Reconcile). A probed key that answers with its cycle's budget spent
// (an OpenRouter key's lift keeps its spend) is out on that budget. The
// OpenRouter price catalogue is fetched when there are models to price.
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 = {});
const std::function<void(const std::string&)>& log, const ProbeFunction& probe = {}, Ledger* ledger = nullptr, std::optional<double> now = std::nullopt,
const OpenRouterProbeFunction& probeKey = {});
void ApplyProbe(Json& seat, const Json& result);
std::string Describe(const Json& seat);

View file

@ -46,13 +46,18 @@ export namespace Crafter::Review::Seats {
Json ConfigFromEnv(const std::map<std::string, std::string>& env);
std::vector<std::pair<std::int32_t, std::string>> SeatTokensFromEnv(const std::map<std::string, std::string>& env);
std::vector<std::pair<std::int32_t, std::string>> ApiKeysFromEnv(const std::map<std::string, std::string>& env);
// {n: day}: ANTHROPIC_API_KEY<n>_GRANT_DAY, the day of the month seat n's
// organization receives its plan's credits (the Console's
// Promotional-credits expiry minus three days), 1..31; anything else is
// ignored.
std::map<std::int32_t, std::int32_t> ApiGrantDays(const std::map<std::string, std::string>& env);
std::vector<std::pair<std::int32_t, std::string>> OpenRouterKeysFromEnv(const std::map<std::string, std::string>& env);
// Every `[export ]NAME=value` line of a dotenv text, in order: blanks
// around the `=` and the value dropped, one pair of matching quotes
// stripped; anything else skipped.
std::vector<std::pair<std::string, std::string>> DotenvLines(std::string_view text);
// Only the seat lines of those: the OAuth tokens, the numbered API keys
// and the OpenRouter keys; everything else ignored.
// with their grant days and the OpenRouter keys; everything else ignored.
std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text);
// The REVIEW_GATES_* lines of a dotenv file (quotes and `export`
// stripped). SeatPool::SettingsOf lays the placement settings among them
@ -142,7 +147,11 @@ export namespace Crafter::Review::Seats {
std::string RenderTable(const std::vector<std::vector<std::string>>& rows);
std::string RenderStatusReport(const std::vector<Json>& seats, const Json& config, double now, const Zone& zone, const Json& board = nullptr,
const std::map<std::int32_t, std::string>& keys = {});
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, const Json& board = nullptr,
// API-credit seats: the probe's verdict beside the driver's (its ledger
// against the budget), the cycle, the grant day and when the next
// credits are due - each seat's ledger, ledgerOut, grantDay, nextCredits
// and the probe's ledger event as the seats command set them.
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, double now, const Json& board = nullptr,
const std::map<std::int32_t, std::string>& keys = {});
std::string RenderDrivers(const Json& board, double now, const Zone& zone);
}

View file

@ -27,6 +27,19 @@
</provides>
<content_rating type="oars-1.1"/>
<releases>
<release version="1.1.0" date="2026-11-02">
<description>
<p>New</p>
<ul>
<li>Each API seat's credit cycle starts on its own grant day, <code>ANTHROPIC_API_KEY&lt;n&gt;_GRANT_DAY</code> in the seats file. A seat out of credit stays out across the rollover until a probe sees its key answer again, and <code>seats</code> shows each API seat's grant day, when its next credits are due and whether the driver holds it out.</li>
<li>Leaving <code>authors</code> out of the config turns the freeze's author and committer check off, where it used to check <code>git config user.email</code>. An empty list is a config error.</li>
</ul>
<p>Fixes</p>
<ul>
<li>An OpenRouter key that ran out of credit is probed again at every run instead of waiting for the month's turn.</li>
</ul>
</description>
</release>
<release version="1.0.1" date="2026-11-02">
<description>
<p>Fixes</p>

View file

@ -136,7 +136,7 @@ delta `@{upstream}..HEAD`, the rule for a repository that pushes to its
own trunk; `base`: always the merge-base with `base`, the whole pull request
for a repository whose branches become PRs — their commits must be linear,
rebased, never a merge of the integration branch), `authors` (the emails the
freeze accepts; null = `git config user.email`), `aiTrailers` (`forbid` or
freeze accepts; null = neither author nor committer is checked), `aiTrailers` (`forbid` or
`allow`), `requireSigned`, `allowDirty` (tracked
files the local stack rewrites in place while the lenses run), `gate0` (the
commands Gate 0 runs by hand, for the skill), `modes` (`modes.<name>`: the
@ -209,8 +209,9 @@ default), `notes`.
output where a commit message's "tested" claim can point at it
(`--context-paths`).
2. **Freeze** — `freeze` checks, all of them, before it
exports anything: author AND committer are in `authors`; AI attribution
trailers (`Assisted-by:`, `Co-Authored-By:`) by the `aiTrailers` rule; the
exports anything: author AND committer are in `authors` (when it is
set); AI attribution trailers (`Assisted-by:`, `Co-Authored-By:`) by the
`aiTrailers` rule; the
tracked tree is clean (the `allowDirty` paths aside, warned); no merge
commit in the range (`git format-patch` drops them silently, so the
artifact would not be the push); the range is a fast-forward of

View file

@ -1,6 +1,6 @@
---
name: prepush
description: Pre-push gates for a repository set up for crafter-review (`crafter-review init`). Runs the pipeline's procedure end to end — Gate 0 build/tests + freeze (identity, trailers by the config's rule, clean tree, no merges, fast-forward), the application and the database under test brought up from the frozen tree, the driver run of the lenses the config turns on - every lens is off until it does (repo-better-solution → repo-functional with the Playwright MCP → repo-bug-prover and repo-security side by side → repo-facts, the commit truth) via crafter-review, the fix/rerun loop, the owner's approval of the hash, verify, push. Nothing is pushed unreviewed or unapproved.
description: Pre-push gates for a repository set up for crafter-review (`crafter-review init`). Runs the pipeline's procedure end to end — Gate 0 build/tests + freeze (identity when `authors` is set, trailers by the config's rule, clean tree, no merges, fast-forward), the application and the database under test brought up from the frozen tree, the driver run of the lenses the config turns on - every lens is off until it does (repo-better-solution → repo-functional with the Playwright MCP → repo-bug-prover and repo-security side by side → repo-facts, the commit truth) via crafter-review, the fix/rerun loop, the owner's approval of the hash, verify, push. Nothing is pushed unreviewed or unapproved.
---
Follow the pipeline's procedure, `prepush.md`, EXACTLY — read it now, in

View file

@ -37,6 +37,12 @@ model=
prev=
for a in "$@"; do [ "$prev" = --model ] && model=$a; prev=$a; done
cat > /dev/null
# A Console key whose credit is gone, answering after two seconds.
if [ "$ANTHROPIC_API_KEY" = sk-ant-api-spent ]; then
sleep 2
echo '{"type":"result","subtype":"success","is_error":true,"result":"Credit balance is too low","total_cost_usd":0}'
exit 1
fi
now=$(date +%s)
fable=
[ "$model" = claude-fable-5-1 ] && fable=",\"seven_day_overage_included\":{\"utilization\":0.3,\"resetsAt\":$((now+172800))}"
@ -340,7 +346,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
// --env (a prefix of --env-file) names the file for the tokens and
// the settings alike, with ./.env in the way (the API seat's section
// is where the order shows).
Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nREVIEW_GATES_SEAT_ORDER=oauth-first\n");
Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=16\nREVIEW_GATES_SEAT_ORDER=oauth-first\n");
const Process::Captured other = Self({"seats", "--no-fable", "--env", "other.env"}, base / "seats", withFake);
// An application's .env (no seat line, no setting) is passed over for
// the machine's own seats file.
@ -365,8 +371,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board; an application's .env "
"is passed over for the machine's own file; a FIFO is read once, its settings and its seats alike",
report.returnCode == 0 && report.out.starts_with("seat pool: 3 seat(s) (local, 1, api1) from .env and your own login, probed ")
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running")
&& other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | $0.00 of $200")
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | ok | $0.00 of $200") && report.out.contains("drivers: none running")
&& other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | ok | $0.00 of $200") && other.out.contains("| 16 |")
&& other.out.contains("oauth seats run first (REVIEW_GATES_SEAT_ORDER=oauth-first)") && !other.out.contains("tok-two") && !other.out.contains("sk-ant-api-two")
&& appSeats.returnCode == 0 && appSeats.out.starts_with(std::format("seat pool: 1 seat(s) (3) from {}, probed ", (base / "app-xdg" / "crafter-review" / "env").string()))
&& fromFifo.returnCode == 0 && fromFifo.out.starts_with("seat pool: 2 seat(s) (4, api4) from seats.fifo, probed ")
@ -377,6 +383,29 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
&& parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z")
&& !(report.out + json.out).contains("tok-one") && !(report.out + json.out).contains("sk-ant-api-one"),
json.out.substr(0, 600) + json.err);
// The credit error a probe reads is recorded in the ledger stamped
// when it is written, not at the start the probes share (1.1.0 round
// 3): the spent key answers two seconds after the command began.
Write(base / "spent" / ".env", "ANTHROPIC_API_KEY3=sk-ant-api-spent\n");
const double spentStart = Platform::Now();
const Process::Captured spent = Self({"seats", "--json"}, base / "spent", withFake);
Json spentParsed;
try {
spentParsed = Json::parse(spent.out);
} catch (const std::exception&) {
}
// The child's TZ is UTC (Env): the stamp is compared in UTC.
const auto utc = [](double epoch) {
return std::format("{:%Y-%m-%dT%H:%M:%S}", std::chrono::floor<std::chrono::seconds>(std::chrono::sys_seconds(std::chrono::seconds(static_cast<std::int64_t>(epoch)))));
};
Json spentEntry;
if (spentParsed.is_object() && spentParsed.contains("apiSeats") && !spentParsed.at("apiSeats").empty()) {
spentEntry = Dyn::Get(Dyn::Get(spentParsed.at("apiSeats").at(0), "ledger"), "outSince");
}
Check("seats: a credit error the probe read is recorded in the shared ledger stamped when written, after the probe, never at the probes' common start",
spent.returnCode == 0 && spentParsed.is_object() && spentParsed.at("apiSeats").at(0).at("event") == "credit-gone" && spentEntry.is_string()
&& Dyn::Slice(spentEntry.get<std::string>(), 0, 19) >= utc(spentStart + 2),
spent.out.substr(0, 800) + spent.err);
const Process::Captured none = Self({"seats"}, base / "plain", withFake);
const Process::Captured noFile = Self({"seats", "--env-file", "missing.env"}, base / "plain", withFake);
Write(base / "plain" / "bad.env", std::string("\xff\n", 2));

View file

@ -233,12 +233,11 @@ int main() {
&& ex.at("modes").at("commits").at("lenses").size() == 5 && ex.at("modes").at("series").at("lenses").size() == 10 && ex.at("modes").at("reply").at("lenses").size() == 5
&& ex.at("modes").at("commits").at("effort") == "xhigh" && ex.at("modes").at("series").at("effort") == "max" && ex.at("modes").at("reply").at("effort") == "max"
&& std::ranges::none_of(ex.at("lenses"), [](const Json& entry) { return entry.contains("run"); }), Show(Config::Validate(ex)));
const fs::path repo = td / "r";
fs::create_directories(repo);
Process::Run({"git", "-C", repo.string(), "init", "-q"});
Process::Run({"git", "-C", repo.string(), "config", "user.email", "me@example.com"});
Check("authors: the configured list, else git config user.email", Config::Authors({{"authors", {"a@b.c"}}}, repo) == std::vector<std::string>{"a@b.c"}
&& Config::Authors({{"authors", nullptr}}, repo) == std::vector<std::string>{"me@example.com"});
Check("authors: the configured list; null or no key - none, the freeze checks neither author nor committer (2026-11-02, not the git identity)",
Config::Authors({{"authors", {"a@b.c"}}}) == std::vector<std::string>{"a@b.c"} && !Config::Authors({{"authors", nullptr}}) && !Config::Authors(Json::object())
&& !Config::Authors(Config::Defaults()));
problems = bad({{"authors", Json::array()}});
Check("an empty authors list is the problem that says null turns the check off", problems.size() == 1 && problems[0].contains("empty list") && problems[0].contains("null"), Show(problems));
std::ofstream(td / "u.json", std::ios::binary) << "{}";
Check("login_fixture: the first candidate that exists", Config::LoginFixture({{"functional", {{"login", {{"fixtures", {"nope.json", "u.json"}}}}}}}, td) == "u.json"
&& !Config::LoginFixture({{"functional", {{"login", {{"fixtures", Json::array()}}}}}}, td));

View file

@ -79,14 +79,18 @@ int main() {
if (options.date.empty()) options.date = "2026-10-31";
return Freeze::Run(repo, out, options);
};
Json r = freeze({.base = base});
Json r = freeze({.base = base, .authors = std::vector<std::string> {me}});
std::string failures = Joined(r.at("failures"));
Check("trailer (forbid) + foreign author both named, nothing exported; the author list came from git config", !r.at("ok").get<bool>() && failures.contains("Co-Authored-By")
Check("trailer (forbid) + foreign author both named, nothing exported", !r.at("ok").get<bool>() && failures.contains("Co-Authored-By")
&& failures.contains("catbot@bot.local") && !fs::exists(out) && r.at("authors") == Json::array({me}), failures);
r = freeze({.base = base, .aiTrailers = "allow"});
r = freeze({.base = base, .authors = std::vector<std::string> {me}, .aiTrailers = "allow"});
Check("trailer (allow): a warning, the foreign author still fails", !r.at("ok").get<bool>() && Joined(r.at("failures")).contains("catbot")
&& !Joined(r.at("failures")).contains("Co-Authored-By") && AnyContains(r.at("warnings"), "Co-Authored-By"));
r = freeze({.base = base, .authors = {me, "catbot@bot.local"}, .aiTrailers = "allow"});
r = freeze({.base = base});
Check("no authors (null in the config): author and committer unchecked, said in a warning - only the trailer fails", !r.at("ok").get<bool>()
&& Joined(r.at("failures")).contains("Co-Authored-By") && !Joined(r.at("failures")).contains("catbot") && r.at("authors").is_null()
&& AnyContains(r.at("warnings"), "author and committer not checked"), Joined(r.at("failures")));
r = freeze({.base = base, .authors = std::vector<std::string> {me, "catbot@bot.local"}, .aiTrailers = "allow"});
Check("both emails in authors + allow -> the range freezes (3 commits)", r.at("ok").get<bool>() && r.at("files").size() == 3, Joined(r.at("failures")));
g({"reset", "-q", "--hard", clean});
r = freeze({.base = base, .replace = true});

View file

@ -187,6 +187,251 @@ int main() {
if (SeatPool::CycleStart(at, day) != want) missed.push_back(std::format("{} {} -> {}", at, day, SeatPool::CycleStart(at, day)));
}
Check("cycle start: this month's billing day once reached, else last month's, clamped to its length", missed.empty(), Dyn::Join(missed, "; "));
// 2026-10-17 (the Python driver's, ported 2026-11-02): the cycle is the
// seat's grant day - the credits' own, from the Console's expiry minus
// three days - and a key that answered the credit error stays out across
// the rollover until a probe sees it answer again (then: new credits, a
// new cycle). The Python driver's vectors.
{
auto at = [](std::int32_t y, unsigned m, unsigned d) {
using namespace std::chrono;
return static_cast<double>(duration_cast<seconds>((sys_days(year_month_day(year(y), month(m), day(d))) + hours(12)).time_since_epoch()).count());
};
Check("cycle start: a day past the month's end is its last day",
SeatPool::CycleStart(at(2026, 4, 30), 31) == "2026-04-30" && SeatPool::CycleStart(at(2026, 3, 1), 31) == "2026-02-28" && SeatPool::CycleStart(at(2026, 3, 30), 28) == "2026-03-28");
constexpr double t17 = at(2026, 10, 17);
Check("next credits: the grant day after the cycle's; the cycle's own when the seat went out on credit before it",
SeatPool::NextGrant(t17, 17) == "2026-11-17" && SeatPool::NextGrant(t17, 5) == "2026-11-05" && SeatPool::NextGrant(t17, 17, "2026-10-08T20:19:06") == "2026-10-17"
&& SeatPool::NextGrant(t17, 5, "2026-10-13T22:46:02") == "2026-11-05" && SeatPool::NextGrant(at(2026, 12, 20), 31) == "2026-12-31");
Check("ledger verdict: a credit-out, the budget, else ok",
SeatPool::LedgerVerdict({{"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}, {"spentUsd", 0}}, 200)
== std::pair<bool, std::string>(true, "ledger: credit balance too low since 2026-10-08T20:19")
&& SeatPool::LedgerVerdict({{"spentUsd", 200.0}}, 200) == std::pair<bool, std::string>(true, "ledger: $200.00 spent this cycle, budget $200")
&& SeatPool::LedgerVerdict({{"spentUsd", 1}}, 200) == std::pair<bool, std::string>(false, ""));
const std::string text2 = "ANTHROPIC_API_KEY3=sk-x\nANTHROPIC_API_KEY3_GRANT_DAY=16\nANTHROPIC_API_KEY4_GRANT_DAY = 5\nANTHROPIC_API_KEY1_GRANT_DAY=32\nANTHROPIC_API_KEY2_GRANT_DAY=x\nANTHROPIC_API_KEY_GRANT_DAY=9\n";
Check("a grant day: decimal digits of any script, leading zeros of any length; anything else ignored",
Seats::ApiGrantDays({{"ANTHROPIC_API_KEY1_GRANT_DAY", "0000000016"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "\uFF11\uFF16"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "1_6"},
{"ANTHROPIC_API_KEY4_GRANT_DAY", "99999999999999999999"}, {"ANTHROPIC_API_KEY5_GRANT_DAY", "\u00B2"}, {"ANTHROPIC_API_KEY6_GRANT_DAY", "0"}})
== std::map<std::int32_t, std::int32_t> {{1, 16}, {2, 16}});
const std::map<std::string, std::string> tokens2 = Seats::SeatTokensFromEnvFile(text2);
Check("the grant day per seat from the dotenv file, 1..31 only, never a key",
tokens2 == std::map<std::string, std::string> {{"ANTHROPIC_API_KEY3", "sk-x"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "16"}, {"ANTHROPIC_API_KEY4_GRANT_DAY", "5"},
{"ANTHROPIC_API_KEY1_GRANT_DAY", "32"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "x"}}
&& Seats::ApiGrantDays(tokens2) == std::map<std::int32_t, std::int32_t> {{3, 16}, {4, 5}}
&& Seats::ApiKeysFromEnv(tokens2) == std::vector<std::pair<std::int32_t, std::string>> {{3, "sk-x"}});
SeatPool::Ledger old(td / "old.json", acfg,
{{"api1", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}},
{"api2", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", nullptr}, {"outReason", nullptr}}},
{"or1", {{"cycle", "1999-01-01"}, {"spentUsd", 50}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}}});
Check("a new cycle resets the spend; a credit-out carries over until a probe lifts it, an OpenRouter key's too (the month's turn puts nothing on it)",
old.Entry("api1").at("spentUsd") == 0 && old.Entry("api1").at("procs") == 0 && old.Entry("api1").at("outSince") == "x" && old.Entry("api2").at("outSince").is_null()
&& old.Entry("api2").at("spentUsd") == 0 && old.Entry("or1").at("outSince") == "x" && old.Entry("or1").at("outReason") == "y" && old.Entry("or1").at("spentUsd") == 0);
Check("ledger out: credit, budget, else null",
SeatPool::LedgerOut({{"outSince", "2026-10-08T20:19:06"}, {"spentUsd", 0}}, 200) == "credit" && SeatPool::LedgerOut({{"spentUsd", 200.0}}, 200) == "budget"
&& SeatPool::LedgerOut({{"spentUsd", 1}}, 200).is_null());
const fs::path daysEnv = td / "days.env";
Write(daysEnv, "ANTHROPIC_API_KEY1=sk-ant-api-one\nANTHROPIC_API_KEY1_GRANT_DAY=17\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=0\n");
SeatPool::Ledger lg(td / "days.json", acfg,
{{"api1", {{"cycle", "2026-10-01"}, {"spentUsd", 200.18}, {"procs", 112}, {"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}}},
{"api2", {{"cycle", "2026-09-01"}, {"spentUsd", 150.0}, {"procs", 90}, {"outSince", nullptr}, {"outReason", nullptr}}}});
std::vector<Json> ds = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api", &lg, acfg, t17);
Check("the grant day per seat from the seats file (0 is ignored: the global day), into the ledger",
Ids(ds) == "api1,api2" && ds[0].at("grantDay") == 17 && ds[1].at("grantDay") == 1 && lg.Day("api1") == 17 && lg.Day("api2") == 1, Ids(ds));
Check("rollover on the grant day: the spend starts over, a credit-out carries - the seat stays out",
ds[0].at("state") == "out" && ds[0].at("ledgerOut") == "credit" && ds[0].at("spentUsd") == 0 && lg.Entry("api1", t17).at("cycle") == "2026-10-17"
&& lg.Entry("api1", t17).at("procs") == 0 && ds[0].at("error").get<std::string>().contains("since 2026-10-08"),
DumpSpaced(ds[0]));
Check("...a budget-out resets with its cycle",
ds[1].at("state") == "unknown" && Dyn::Get(ds[1], "ledgerOut").is_null() && ds[1].at("spentUsd") == 0 && lg.Entry("api2", t17).at("cycle") == "2026-10-01");
std::vector<std::string> calls;
std::map<std::string, std::string> answers = {{"api1", "ok"}, {"api2", "out"}};
const SeatPool::ProbeFunction fake = [&](std::int32_t n, const std::string&, const std::string& model, double, const std::string&, bool, bool) {
const std::string id = std::format("api{}", n);
calls.push_back(id);
const std::string state = answers.at(id);
return Json {{"seat", n}, {"state", state}, {"windows", Json::object()}, {"probedWith", model},
{"error", state == "ok" ? Json() : Json(state == "out" ? "Your credit balance is too low" : "probe timeout after 1000 ms")}};
};
std::vector<std::string> plog;
auto plogf = [&](const std::string& line) { plog.push_back(line); };
SeatPool::Probe(ds, "claude", 1, {"claude-opus-5-5"}, plogf, fake, &lg, t17);
const Json e1 = lg.Entry("api1", t17);
Check("the run-start probe re-probes a credit-out api seat; a clean answer is new credits: the seat is back, its cycle restarted on its grant day",
calls == std::vector<std::string> {"api1", "api2"} && ds[0].at("state") == "ok" && ds[0].at("ledgerOut").is_null() && ds[0].at("error").is_null() && e1.at("outSince").is_null()
&& e1.at("cycle") == "2026-10-17" && e1.at("grantSeen") == "2026-10-17T12:00:00+00:00"
&& std::ranges::any_of(plog, [](const std::string& line) { return line.contains("new credits"); }),
Dyn::Join(plog, " | "));
const Json e2 = lg.Entry("api2", t17);
Check("...the credit error on a seat the ledger had as fine is recorded, with the next credits in the reason",
ds[1].at("state") == "out" && ds[1].at("ledgerOut") == "credit" && Dyn::Truthy(e2.at("outSince"))
&& e2.at("outSince") == "2026-10-17T12:00:00+00:00" && ds[1].at("error") == "credit balance too low since 2026-10-17T12:00; next credits due 2026-11-01",
Dyn::Str(ds[1].at("error")));
Check("reconcile: nothing to do when the probe and the ledger agree", lg.Reconcile("api1", "ok", t17, t17).empty() && lg.Reconcile("api2", "out", t17, t17).empty());
// Two drivers on one ledger (1.1.0 round 2): the decision is made on
// the file's entry under the lock, never on a copy read before the
// probe, and a mark made since the probe started stands.
const std::string outLine = R"("cycle": "2026-10-17", "spentUsd": 0, "procs": 0, "outSince": "2026-10-08T20:19:06", "outReason": "credit balance too low")";
Write(td / "race1.json", std::format("{{\"api1\": {{{}}}}}", outLine));
SeatPool::Ledger la = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}});
SeatPool::Ledger lb = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}});
const std::string firstGrant = la.Reconcile("api1", "ok", t17, t17);
la.Add("api1", 5.0, t17);
const std::string secondGrant = lb.Reconcile("api1", "ok", t17 + 60, t17 + 60);
const Json race1 = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60);
Check("two drivers: the second's copy, read before the first saw the grant, lifts nothing - the first's grant and its spend stand",
firstGrant == "new-grant" && secondGrant.empty() && race1.at("spentUsd") == 5.0 && race1.at("procs") == 1 && race1.at("outSince").is_null(), DumpSpaced(race1));
Write(td / "race2.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 199.4, "procs": 180, "outSince": "2026-10-17T12:00:06", "outReason": "credit balance too low"}})");
SeatPool::Ledger lc = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}});
const std::string late = lc.Reconcile("api1", "ok", t17, t17 + 30);
const Json race2 = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 30);
Check("a credit error recorded after the probe started outlives its answer: no grant, the spend kept",
late.empty() && race2.at("outSince") == "2026-10-17T12:00:06" && race2.at("spentUsd") == 199.4 && race2.at("procs") == 180, DumpSpaced(race2));
Write(td / "race3.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-10-17T12:00:10"}})");
SeatPool::Ledger ld = SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}});
const std::string stale = ld.Reconcile("api1", "out", t17, t17 + 30);
const std::string fresh = ld.Reconcile("api1", "out", t17 + 60, t17 + 60);
Check("...and a credit error read before a grant another driver saw since records nothing; one read after it does",
stale.empty() && fresh == "credit-gone" && SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60).at("spentUsd") == 2);
// The grant seen at 23:59:55, the cycle turning at midnight (grant
// day 17) before a credit error read at 23:59:40 is reconciled.
Write(td / "race6.json", R"({"api1": {"cycle": "2026-11-16", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-11-16T23:59:55"}})");
SeatPool::Ledger le = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}});
const std::string acrossMidnight = le.Reconcile("api1", "out", at(2026, 11, 16) + 43180, at(2026, 11, 17) - 43198);
const Json race6 = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}}).Entry("api1", at(2026, 11, 17) - 43198);
// The stamps carry their UTC offset and Reconcile orders marks by
// instant (1.1.0 round 4). A POSIX rule, not a zone file: Central
// European time, whose summer time ends 2026-10-25 03:00, 02:00-03:00
// then passing twice.
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
constexpr double fallBack = at(2026, 10, 25) - 39610;
Write(td / "race7.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 150, "procs": 120, "outSince": null, "outReason": null}})");
SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", fallBack + 15);
const std::string afterFallBack = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", fallBack, fallBack + 16);
const Json race7 = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Entry("api1", fallBack + 16);
// A stamp without an offset, as older drivers wrote, is local time:
// 13:30 CEST is 11:30 UTC, before a probe started at 12:00 UTC (14:00
// CEST) - new credits; read as UTC it would be after the probe.
Write(td / "race9.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 3, "procs": 1, "outSince": "2026-10-17T13:30:00", "outReason": "credit balance too low"}})");
const std::string legacy = SeatPool::Ledger::Load(td / "race9.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30);
setenv("TZ", "UTC", 1);
tzset();
Check("a credit error recorded after the probe started outlives its answer in the hour DST repeats: 02:00:05 CET is after 02:59:50 CEST",
afterFallBack.empty() && race7.at("outSince") == "2026-10-25T02:00:05+01:00" && race7.at("spentUsd") == 150, DumpSpaced(race7));
Write(td / "race8.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": "2026-10-17T11:00:06-01:00", "outReason": "credit balance too low"}})");
const std::string otherZone = SeatPool::Ledger::Load(td / "race8.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30);
Check("...and from a driver in another zone: its 11:00:06-01:00 is after a probe started at 12:00:00 UTC", otherZone.empty());
Check("a stamp without an offset, as older drivers wrote, is read as local time: 13:30 CEST is before a probe started at 14:00 CEST", legacy == "new-grant", legacy);
Write(td / "race10.json", std::format("{{\"api1\": {{{}}}}}", outLine));
const std::string race10Before = Platform::ReadFile(td / "race10.json").value_or("");
const std::string unread = SeatPool::Ledger::Load(td / "race10.json", acfg, {{"api1", 17}}).Reconcile("api1", "unknown", t17, t17);
Check("a reading neither ok nor the credit error writes nothing", unread.empty() && Platform::ReadFile(td / "race10.json").value_or("") == race10Before);
// OpenRouter keys (1.1.0, Jorijn: a prepaid key has no cycle of its
// own): one the ledger holds out on credit is probed again through
// the key probe, and room on its limit lifts the mark with its
// cycle's spend kept; the limit reached is recorded; one out on its
// budget is not probed. Their cycle is the cycle day's.
Write(td / "or.env", "OPENROUTER_KEY1=or-one\nOPENROUTER_KEY2=or-two\nOPENROUTER_KEY3=or-three\n");
Write(td / "or-ledger.json", R"({"or1": {"cycle": "2026-10-01", "spentUsd": 7.5, "procs": 3, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"},
"or2": {"cycle": "2026-10-01", "spentUsd": 1, "procs": 1, "outSince": null, "outReason": null},
"or3": {"cycle": "2026-10-01", "spentUsd": 150, "procs": 90, "outSince": null, "outReason": null}})");
SeatPool::Ledger orLedger = SeatPool::Ledger::Load(td / "or-ledger.json", acfg);
std::vector<Json> orSeats = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or.env").string(), td)), "openrouter", &orLedger, acfg, t17);
std::vector<std::string> keyCalls;
const SeatPool::OpenRouterProbeFunction fakeKey = [&](const std::string& secret, double) {
keyCalls.push_back(secret);
return secret == "or-one" ? Json {{"state", "ok"}, {"remaining", 3.0}} : Json {{"state", "out"}, {"error", "key limit reached ($10.00 of $10 used)"}, {"remaining", 0}};
};
plog.clear();
SeatPool::Probe(orSeats, "claude", 1, {}, plogf, fake, &orLedger, t17, fakeKey);
SeatPool::Ledger orFile = SeatPool::Ledger::Load(td / "or-ledger.json", acfg);
const Json or1 = orFile.Entry("or1", t17);
const Json or2 = orFile.Entry("or2", t17);
Check("an OpenRouter key held out on credit is probed again, one out on its budget is not; room on its limit lifts the mark and keeps its cycle's spend",
Ids(orSeats) == "or1,or2,or3" && keyCalls == std::vector<std::string> {"or-one", "or-two"} && orSeats[0].at("state") == "ok" && orSeats[0].at("ledgerOut").is_null()
&& orSeats[0].at("spentUsd") == 7.5 && or1.at("outSince").is_null() && or1.at("spentUsd") == 7.5 && or1.at("procs") == 3 && or1.at("cycle") == "2026-10-01"
&& or1.at("grantSeen") == "2026-10-17T12:00:00+00:00" && std::ranges::any_of(plog, [](const std::string& line) { return line.contains("limit has room again"); })
&& orSeats[2].at("state") == "out" && orSeats[2].at("ledgerOut") == "budget",
std::format("{} {}", DumpSpaced(or1), Dyn::Join(keyCalls, ",")));
Check("...and the limit reached is recorded as the key's credit-out, the probe's error the seat's reason",
orSeats[1].at("state") == "out" && orSeats[1].at("ledgerOut") == "credit" && orSeats[1].at("error") == "key limit reached ($10.00 of $10 used)"
&& or2.at("outSince") == "2026-10-17T12:00:00+00:00" && or2.at("outReason") == "key limit reached",
DumpSpaced(or2));
// A key that answers with its cycle's budget spent is out on that
// budget, not left ok - neither placed nor out, a pool of it waiting
// forever (1.1.0 round 9): an OpenRouter key held out on credit with
// its spend over the budget, and an API key another driver lifted and
// spent the budget on since this copy was read.
Write(td / "or-spent.env", "OPENROUTER_KEY1=or-one\n");
Write(td / "or-spent.json",
R"({"or1": {"cycle": "2026-10-01", "spentUsd": 100.4, "procs": 40, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"}})");
SeatPool::Ledger spentLedger = SeatPool::Ledger::Load(td / "or-spent.json", acfg);
std::vector<Json> spentSeats =
SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or-spent.env").string(), td)), "openrouter", &spentLedger, acfg, t17);
SeatPool::Probe(spentSeats, "claude", 1, {}, plogf, fake, &spentLedger, t17, fakeKey);
Check("an OpenRouter key held out on credit with its budget spent too: the mark lifted, the seat out on the budget, a pool of it all out",
spentSeats[0].at("state") == "out" && spentSeats[0].at("ledgerOut") == "budget" && SeatPool::AllOut(spentSeats)
&& SeatPool::Ledger::Load(td / "or-spent.json", acfg).Entry("or1", t17).at("outSince").is_null(),
DumpSpaced(spentSeats[0]));
Write(td / "race11.json", std::format("{{\"api1\": {{{}}}}}", outLine));
SeatPool::Ledger stale11(SeatPool::Ledger::Load(td / "race11.json", acfg));
std::vector<Json> mine11 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale11, acfg, t17);
SeatPool::Ledger other11 = SeatPool::Ledger::Load(td / "race11.json", acfg, {{"api1", 17}});
other11.Reconcile("api1", "ok", t17, t17);
other11.Add("api1", 12.0, t17);
answers["api1"] = "ok";
SeatPool::Probe(mine11, "claude", 1, {}, plogf, fake, &stale11, t17 + 60);
Check("...and an API key another driver lifted and spent its budget on since: out on the budget",
mine11[0].at("state") == "out" && mine11[0].at("ledgerOut") == "budget" && mine11[0].at("spentUsd") == 12.0 && SeatPool::AllOut(mine11), DumpSpaced(mine11[0]));
Check("an OpenRouter key's cycle starts on the cycle day", orLedger.Day("or1") == 1 && SeatPool::Ledger::Load(td / "or-ledger.json", acfg).Entry("or1", at(2026, 11, 2)).at("cycle") == "2026-11-01");
Check("...across the cycle's turn too: the grant's time carries over the rollover with an API seat's entry",
acrossMidnight.empty() && race6.at("outSince").is_null() && race6.at("grantSeen") == "2026-11-16T23:59:55" && race6.at("cycle") == "2026-11-17", DumpSpaced(race6));
Write(td / "race4.json", std::format("{{\"api1\": {{{}}}}}", outLine));
SeatPool::Ledger stale4(SeatPool::Ledger::Load(td / "race4.json", acfg));
std::vector<Json> mine = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale4, acfg, t17);
SeatPool::Ledger other4 = SeatPool::Ledger::Load(td / "race4.json", acfg, {{"api1", 17}});
other4.Reconcile("api1", "ok", t17, t17);
other4.Add("api1", 5.0, t17);
answers["api1"] = "ok";
plog.clear();
SeatPool::Probe(mine, "claude", 1, {}, plogf, fake, &stale4, t17 + 60);
Check("the run's probe after another driver's grant: the seat is back with that driver's spend, no second grant",
mine[0].at("state") == "ok" && mine[0].at("spentUsd") == 5.0 && mine[0].at("ledgerOut").is_null()
&& std::ranges::none_of(plog, [](const std::string& line) { return line.contains("new credits"); })
&& SeatPool::Ledger::Load(td / "race4.json", acfg).Entry("api1", t17 + 60).at("spentUsd") == 5.0,
DumpSpaced(mine[0]));
Write(td / "race5.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null}})");
SeatPool::Ledger mine5(SeatPool::Ledger::Load(td / "race5.json", acfg));
std::vector<Json> seats5 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &mine5, acfg, t17);
SeatPool::Ledger::Load(td / "race5.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", t17 + 6);
SeatPool::Probe(seats5, "claude", 1, {}, plogf, fake, &mine5, t17);
Check("...and the run's probe that answered before another driver's credit error: the seat is out on the ledger's verdict",
seats5[0].at("state") == "out" && seats5[0].at("ledgerOut") == "credit" && seats5[0].at("error") == "ledger: credit balance too low since 2026-10-17T12:00",
DumpSpaced(seats5[0]));
calls.clear();
answers["api1"] = "unknown";
Json apiB = With(ds[1], {{"state", "out"}, {"ledgerOut", "budget"}, {"error", "ledger: $10.00 spent this cycle, budget $10"}});
Json apiU = With(ds[0], {{"state", "out"}, {"ledgerOut", "credit"}, {"error", "ledger: credit balance too low since 2026-10-08T20:19"}});
std::vector<Json> pair2 = {apiB, apiU};
SeatPool::Probe(pair2, "claude", 1, {}, plogf, fake, nullptr, t17);
Check("a budget-out seat is not probed; a credit-out seat whose probe is unreadable stays out on the ledger's verdict",
calls == std::vector<std::string> {"api1"} && pair2[0].at("state") == "out" && pair2[1].at("state") == "out"
&& pair2[1].at("error") == "ledger: credit balance too low since 2026-10-08T20:19 (probe unreadable: probe timeout after 1000 ms)",
Dyn::Str(pair2[1].at("error")));
// A grant's cycle, through the production path: the credit error
// recorded, then a probe that started after it answers.
lg.SetDay("api3", 5);
lg.MarkOut("api3", "credit balance too low", t17 - 60);
const std::string grant3 = lg.Reconcile("api3", "ok", t17, t17);
Check("new credits seen 12 days after the configured day: the cycle starts today, not on that day",
grant3 == "new-grant" && lg.Entry("api3", t17).at("cycle") == "2026-10-17" && lg.Entry("api3", t17).at("spentUsd") == 0);
lg.SetDay("api4", 16);
lg.MarkOut("api4", "credit balance too low", t17 - 60);
const std::string grant4 = lg.Reconcile("api4", "ok", t17, t17);
Check("...and on the day when it is within the week", grant4 == "new-grant" && lg.Entry("api4", t17).at("cycle") == "2026-10-16"
&& lg.Entry("api4", at(2026, 11, 15)).at("cycle") == "2026-10-16"
&& lg.Entry("api4", at(2026, 11, 16)).at("cycle") == "2026-11-16");
}
const std::string knobs = DumpSpaced(SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", " 1_0 "},
{"REVIEW_GATES_API_CONCURRENCY", "3.9"},
{"REVIEW_GATES_API_CYCLE_DAY", "40"},
@ -194,7 +439,7 @@ int main() {
{"REVIEW_GATES_OPENROUTER_BUDGET_USD", "-1"},
{"REVIEW_GATES_API_LENS_COST_USD", "nan"}}));
Check("api config: float() parsing, int() truncation, the cycle day clamped, a negative or NaN value the default",
knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 28, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs);
knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 31, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs);
const double cost = SeatPool::OpenRouterCost("openrouter/xiaomi/mimo-v2.6-pro", {{"input", 1234}, {"cacheCreation", 10}, {"cacheRead", 99999}, {"output", 777}});
Check("openrouter cost: tokens x the built-in list prices, an unknown model free", cost == 0.001577 && SeatPool::OpenRouterCost("nope", {{"input", 1}}) == 0.0, Dyn::FloatRepr(cost));
Json described = SeatPool::NewSeat("1", "oauth", "t");
@ -222,7 +467,7 @@ int main() {
lines == std::vector<std::pair<std::string, std::string>> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}, {"CLAUDE_CODE_OAUTH_TOKEN1", "x"}, {"REVIEW_GATES", "nope"}}
&& settings == std::map<std::string, std::string> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}}
&& Seats::SeatTokensFromEnvFile("ANTHROPIC_API_KEY=bare\nANTHROPIC_API_KEY2=k\nANTHROPIC_API_KEY2_GRANT_DAY=17\nCLAUDE_CODE_OAUTH_TOKEN=t\nCLAUDE_CODE_OAUTH_TOKEN7=no\n")
== std::map<std::string, std::string> {{"ANTHROPIC_API_KEY2", "k"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}});
== std::map<std::string, std::string> {{"ANTHROPIC_API_KEY2", "k"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "17"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}});
const fs::path envTd = fs::temp_directory_path() / std::format("crafter-review-envfile-{}", Platform::PublishedPid());
fs::remove_all(envTd);
fs::create_directories(envTd / "repo");
@ -248,7 +493,7 @@ int main() {
&& !applied.env.contains("REVIEW_GATES_CLAUDE_BIN") && !applied.env.contains("REVIEW_GATES_SEAT_POLICY") && api.at("order") == "oauth-first" && api.at("budgetUsd") == 75.0
&& !Platform::GetEnv("REVIEW_GATES_SEAT_ORDER") && !Platform::GetEnv("REVIEW_GATES_CLAUDE_BIN") && Platform::GetEnv("REVIEW_GATES_API_BUDGET_USD") == "75"
&& SeatPool::SettingsOf(SeatPool::ResolveSeatsFile(std::string(), envTd / "repo")).fromFile.empty() && SeatPool::SettingsOf(missing).fromFile.empty() && raised == "FileNotFoundError",
Dyn::Str(api) + " " + raised);
std::format("{} {}", Dyn::Str(api), raised));
// Every name on the list moves a placement reader (the list is the
// readers' names, not a guess at them).
const Json apiDefault = SeatPool::ApiConfig({});

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