feat(run): crafter-review run and slots, the driver of the gates
Run: gate 0 (the artifact, the trees, the inventory, the run lock, the scratch dir, dispositions, reuse state, resume, reads per round, seats, the board), the stack and migration preflights, the prompts, reuse of whole and block verdicts, salvage, a gate on the pool, checkpoints, the dump, the dry-run report and the unit-state store; slots_plan and app_ready beside it. The CLI parses run's 28 options, types and choices included. A full run takes its lifetime lock beside the board before it joins it. A signal - or the stop file `crafter-review stop` writes, looked at every 2 s - stops the run once gate 0 is through, and the stop stays in force for the gates after; before that it ends the process with its descendants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
554a5ddd48
commit
0f803d90bd
9 changed files with 2639 additions and 13 deletions
|
|
@ -12,6 +12,9 @@ import :Config;
|
||||||
import :Freeze;
|
import :Freeze;
|
||||||
import :PgWire;
|
import :PgWire;
|
||||||
import :Totp;
|
import :Totp;
|
||||||
|
import :Plan;
|
||||||
|
import :OpenCode;
|
||||||
|
import :Run;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
|
|
@ -51,12 +54,25 @@ inventory unit, against a frozen commit range.)";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the option parser these commands use ---------------------------------
|
// ---- the option parser these commands use ---------------------------------
|
||||||
|
enum class Kind {
|
||||||
|
String,
|
||||||
|
Int,
|
||||||
|
Float,
|
||||||
|
};
|
||||||
|
|
||||||
struct OptionSpec {
|
struct OptionSpec {
|
||||||
std::string_view name;
|
std::string_view name;
|
||||||
// A value follows (`--x V` or `--x=V`); else a store_true flag.
|
// A value follows (`--x V` or `--x=V`); else a store_true flag.
|
||||||
bool takesValue;
|
bool takesValue;
|
||||||
std::string_view metavar;
|
std::string_view metavar;
|
||||||
std::string_view help;
|
std::string_view help;
|
||||||
|
// An integer or a float option: the value must parse as
|
||||||
|
// Dyn::ParseInt / Dyn::ParseFloat takes it.
|
||||||
|
Kind kind = Kind::String;
|
||||||
|
std::span<const std::string_view> choices = {};
|
||||||
|
bool required = false;
|
||||||
|
// Left out of --help.
|
||||||
|
bool hidden = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Parsed {
|
struct Parsed {
|
||||||
|
|
@ -78,7 +94,9 @@ inventory unit, against a frozen commit range.)";
|
||||||
std::string UsageLine(std::string_view prog, std::span<const OptionSpec> specs) {
|
std::string UsageLine(std::string_view prog, std::span<const OptionSpec> specs) {
|
||||||
std::string line = std::format("usage: {} [-h]", prog);
|
std::string line = std::format("usage: {} [-h]", prog);
|
||||||
for (const OptionSpec& spec : specs) {
|
for (const OptionSpec& spec : specs) {
|
||||||
line += spec.takesValue ? std::format(" [{} {}]", spec.name, spec.metavar) : std::format(" [{}]", spec.name);
|
if (spec.hidden) continue;
|
||||||
|
const std::string text = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name);
|
||||||
|
line += spec.required ? std::format(" {}", text) : std::format(" [{}]", text);
|
||||||
}
|
}
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +104,7 @@ inventory unit, against a frozen commit range.)";
|
||||||
std::string Help(std::string_view prog, std::string_view description, std::span<const OptionSpec> specs) {
|
std::string Help(std::string_view prog, std::string_view description, std::span<const OptionSpec> specs) {
|
||||||
std::string text = std::format("{}\n\n{}\n\noptions:\n -h, --help show this help message and exit\n", UsageLine(prog, specs), description);
|
std::string text = std::format("{}\n\n{}\n\noptions:\n -h, --help show this help message and exit\n", UsageLine(prog, specs), description);
|
||||||
for (const OptionSpec& spec : specs) {
|
for (const OptionSpec& spec : specs) {
|
||||||
|
if (spec.hidden) continue;
|
||||||
const std::string left = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name);
|
const std::string left = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name);
|
||||||
text += left.size() < 20 ? std::format(" {:<20} {}\n", left, spec.help) : std::format(" {}\n {}\n", left, spec.help);
|
text += left.size() < 20 ? std::format(" {:<20} {}\n", left, spec.help) : std::format(" {}\n {}\n", left, spec.help);
|
||||||
}
|
}
|
||||||
|
|
@ -142,24 +161,51 @@ inventory unit, against a frozen commit range.)";
|
||||||
parsed.flags.insert(key);
|
parsed.flags.insert(key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
std::string value;
|
||||||
if (equals != std::string_view::npos) {
|
if (equals != std::string_view::npos) {
|
||||||
parsed.values[key] = std::string(arg.substr(equals + 1));
|
value = std::string(arg.substr(equals + 1));
|
||||||
} else if (i + 1 < args.size() && !(args[i + 1].starts_with('-') && args[i + 1].size() > 1)) {
|
} else if (i + 1 < args.size() && !(args[i + 1].starts_with('-') && args[i + 1].size() > 1)) {
|
||||||
parsed.values[key] = std::string(args[++i]);
|
value = std::string(args[++i]);
|
||||||
} else {
|
} else {
|
||||||
parsed.error = std::format("argument {}: expected one argument", spec.name);
|
parsed.error = std::format("argument {}: expected one argument", spec.name);
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
if (spec.kind == Kind::Int && !Dyn::ParseInt(value)) {
|
||||||
|
parsed.error = std::format("argument {}: invalid int value: {}", spec.name, Dyn::Repr(value));
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
if (spec.kind == Kind::Float && !Dyn::ParseFloat(value)) {
|
||||||
|
parsed.error = std::format("argument {}: invalid float value: {}", spec.name, Dyn::Repr(value));
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
if (!spec.choices.empty() && !std::ranges::contains(spec.choices, value)) {
|
||||||
|
std::vector<std::string> quoted;
|
||||||
|
for (const std::string_view c : spec.choices) quoted.push_back(Dyn::Repr(c));
|
||||||
|
parsed.error = std::format("argument {}: invalid choice: {} (choose from {})", spec.name, Dyn::Repr(value), Dyn::Join(quoted, ", "));
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
parsed.values[key] = std::move(value);
|
||||||
}
|
}
|
||||||
if (!unrecognized.empty()) parsed.error = std::format("unrecognized arguments: {}", Dyn::Join(unrecognized, " "));
|
if (!unrecognized.empty()) {
|
||||||
|
parsed.error = std::format("unrecognized arguments: {}", Dyn::Join(unrecognized, " "));
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
std::vector<std::string> missing;
|
||||||
|
for (const OptionSpec& spec : specs) {
|
||||||
|
if (spec.required && !parsed.values.contains(spec.name.substr(2))) missing.emplace_back(spec.name);
|
||||||
|
}
|
||||||
|
if (!missing.empty()) parsed.error = std::format("the following arguments are required: {}", Dyn::Join(missing, ", "));
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The consumer config, loaded before every command but `config`:
|
// The consumer config, loaded before every command but `config`:
|
||||||
// warnings on stderr, a problem stops it.
|
// warnings on stderr (`run` logs them with its other lines), a problem
|
||||||
std::optional<Json> LoadConfig(const std::optional<std::string>& configPath) {
|
// stops it.
|
||||||
|
std::optional<Json> LoadConfig(const std::optional<std::string>& configPath, bool warn = true) {
|
||||||
Config::Loaded loaded = Config::Load(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
Config::Loaded loaded = Config::Load(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
||||||
for (const Json& warning : Dyn::Or(Dyn::Get(loaded.config, "_warnings"), Dyn::EmptyList())) std::println(std::cerr, "config: warning: {}", warning.get<std::string>());
|
if (warn) {
|
||||||
|
for (const Json& warning : Dyn::Or(Dyn::Get(loaded.config, "_warnings"), Dyn::EmptyList())) std::println(std::cerr, "config: warning: {}", warning.get<std::string>());
|
||||||
|
}
|
||||||
if (!loaded.problems.empty()) {
|
if (!loaded.problems.empty()) {
|
||||||
Die(std::format("config: {}", Dyn::Join(loaded.problems, "; ")));
|
Die(std::format("config: {}", Dyn::Join(loaded.problems, "; ")));
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
|
|
@ -179,6 +225,152 @@ inventory unit, against a frozen commit range.)";
|
||||||
{"--verify", false, "", "push-time check: HEAD, a clean tree and a fresh export still match freeze.json"},
|
{"--verify", false, "", "push-time check: HEAD, a clean tree and a fresh export still match freeze.json"},
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
constexpr std::array<std::string_view, 1> Modes = {"commits"};
|
||||||
|
constexpr std::array<std::string_view, 2> Harnesses = {"claude", "opencode"};
|
||||||
|
|
||||||
|
const std::vector<OptionSpec>& RunOptions() {
|
||||||
|
static const std::string effort = std::format("reasoning effort of every lens process (default {})", Plan::Effort);
|
||||||
|
static const std::string reads =
|
||||||
|
std::format("clean reads a code unit of the static code lenses needs before its verdict is reused, one per round (default {})", Plan::Reads);
|
||||||
|
static const std::string models = [] {
|
||||||
|
std::vector<std::string> keys;
|
||||||
|
for (const Plan::ModelTier& m : Plan::Models()) keys.push_back(m.tier);
|
||||||
|
return std::format("override the lens model table, e.g. fable=claude-fable-5-1 (keys: {})", Dyn::Join(keys, ","));
|
||||||
|
}();
|
||||||
|
static const std::string variant = std::format("opencode harness: the model's reasoning variant (default {})", OpenCode::DefaultVariant);
|
||||||
|
static const std::vector<OptionSpec> specs = {
|
||||||
|
{"--mode", true, "MODE", "", Kind::String, Modes, false, true},
|
||||||
|
{"--artifact", true, "ARTIFACT", "the frozen artifact dir (.review-gates/push/<slug>/)", Kind::String, {}, true},
|
||||||
|
{"--hash", true, "HASH", ""},
|
||||||
|
{"--dispositions", true, "DISPOSITIONS", ""},
|
||||||
|
{"--base", true, "BASE", ""},
|
||||||
|
{"--date", true, "DATE", ""},
|
||||||
|
{"--trees", true, "TREES", "the checkout the lenses read, at the range HEAD: the repository itself (`.`, default) or the frozen worktree"},
|
||||||
|
{"--context-paths", true, "CONTEXT_PATHS", "comma-separated paths of material the artifact discusses (captures, evidence) - mechanical pointers, no narrative"},
|
||||||
|
{"--only", true, "ONLY", "comma-separated lens files (test only)"},
|
||||||
|
{"--without", true, "WITHOUT", "comma-separated lens files to leave out of this run by owner ruling (logged; the run stays a full run)"},
|
||||||
|
{"--units", true, "UNITS", "comma-separated unit ids (test only)"},
|
||||||
|
{"--warm", false, "", "test runs only: let one pass per prompt prefix go first, as a full run does"},
|
||||||
|
{"--concurrency", true, "CONCURRENCY",
|
||||||
|
"lens processes in flight at once across all seats (default 128, 2026-10-15; the pool still places each one by seat headroom, launches a pass only when it fits in free "
|
||||||
|
"memory at its environment's measured cost, and gives a pass with the browser a stack of its own)",
|
||||||
|
Kind::Int},
|
||||||
|
{"--effort", true, "EFFORT", effort},
|
||||||
|
{"--reads", true, "READS", reads, Kind::Int},
|
||||||
|
{"--no-reuse", false, "", ""},
|
||||||
|
{"--no-salvage", false, "", "do not reuse finished process outputs of an earlier run on this hash"},
|
||||||
|
{"--dry-run", false, "", ""},
|
||||||
|
{"--claude-bin", true, "CLAUDE_BIN", ""},
|
||||||
|
{"--models", true, "MODELS", models},
|
||||||
|
{"--scratch", true, "SCRATCH", ""},
|
||||||
|
{"--seats", true, "SEATS", "the seats the lenses run on: auto (every Anthropic seat in the env file, else local), local, api, oauth, openrouter (never in auto), or a list like api1,1,2,local"},
|
||||||
|
{"--env-file", true, "ENV_FILE", "dotenv file with CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6 (default $SEATS_ENV_FILE, else the repository .env)"},
|
||||||
|
{"--no-probe", false, "", "skip the one-request probe of every seat at the start"},
|
||||||
|
{"--api-budget", true, "API_BUDGET", "USD an API-credit seat may spend per cycle (default REVIEW_GATES_API_BUDGET_USD or 200)", Kind::Float},
|
||||||
|
{"--harness", true, "{claude,opencode}",
|
||||||
|
"the agent harness of every lens process: claude (default) or opencode (OpenRouter seats only; no lens whose environment needs an MCP server)", Kind::String, Harnesses},
|
||||||
|
{"--variant", true, "VARIANT", variant},
|
||||||
|
{"--opencode-bin", true, "OPENCODE_BIN", ""},
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int32_t RunCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
|
||||||
|
constexpr std::string_view Prog = "crafter-review run";
|
||||||
|
const std::vector<OptionSpec>& specs = RunOptions();
|
||||||
|
const Parsed parsed = ParseOptions(args, specs);
|
||||||
|
if (parsed.help) {
|
||||||
|
std::print("{}", Help(Prog, "one claude process per inventory unit per lens, the gates, the dump (process/prepush.md)", specs));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, specs), Prog, parsed.error);
|
||||||
|
const std::optional<Json> config = LoadConfig(configPath, false);
|
||||||
|
if (!config) return 3;
|
||||||
|
Run::Arguments a;
|
||||||
|
a.config = configPath;
|
||||||
|
a.mode = parsed.Value("mode").value_or("commits");
|
||||||
|
a.artifact = parsed.Value("artifact").value_or("");
|
||||||
|
a.hash = parsed.Value("hash");
|
||||||
|
a.dispositions = parsed.Value("dispositions");
|
||||||
|
a.base = parsed.Value("base").value_or("");
|
||||||
|
a.date = parsed.Value("date").value_or(Platform::TodayIso());
|
||||||
|
a.trees = parsed.Value("trees");
|
||||||
|
a.contextPaths = parsed.Value("context-paths").value_or("");
|
||||||
|
a.only = parsed.Value("only");
|
||||||
|
a.without = parsed.Value("without");
|
||||||
|
if (const std::optional<std::string> units = parsed.Value("units"); units && !units->empty()) {
|
||||||
|
// A trailing comma names no unit.
|
||||||
|
a.units = std::set<std::string>();
|
||||||
|
for (const std::string& part : Dyn::Split(*units, ",")) {
|
||||||
|
if (std::string id = Dyn::Strip(part); !id.empty()) a.units->insert(std::move(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.warm = parsed.Flag("warm");
|
||||||
|
if (const std::optional<std::string> n = parsed.Value("concurrency")) a.concurrency = *Dyn::ParseInt(*n);
|
||||||
|
a.effort = parsed.Value("effort").value_or("");
|
||||||
|
if (a.effort.empty()) a.effort = std::string(Plan::DefaultEffort());
|
||||||
|
a.noReuse = parsed.Flag("no-reuse");
|
||||||
|
a.noSalvage = parsed.Flag("no-salvage");
|
||||||
|
a.dryRun = parsed.Flag("dry-run");
|
||||||
|
a.claudeBin = parsed.Value("claude-bin");
|
||||||
|
a.models = parsed.Value("models").value_or("");
|
||||||
|
a.scratch = parsed.Value("scratch");
|
||||||
|
a.seats = parsed.Value("seats");
|
||||||
|
if (!a.seats) a.seats = Platform::GetEnv("REVIEW_GATES_SEATS");
|
||||||
|
a.envFile = parsed.Value("env-file");
|
||||||
|
a.noProbe = parsed.Flag("no-probe");
|
||||||
|
if (const std::optional<std::string> budget = parsed.Value("api-budget")) a.apiBudget = *Dyn::ParseFloat(*budget);
|
||||||
|
a.harness = parsed.Value("harness").value_or("claude");
|
||||||
|
a.variant = parsed.Value("variant");
|
||||||
|
if (!a.variant || a.variant->empty()) a.variant = a.harness == "opencode" ? std::optional<std::string>(std::string(OpenCode::DefaultVariant)) : std::nullopt;
|
||||||
|
a.opencodeBin = parsed.Value("opencode-bin");
|
||||||
|
if (const std::optional<std::string> reads = parsed.Value("reads")) {
|
||||||
|
const std::int64_t n = *Dyn::ParseInt(*reads);
|
||||||
|
if (n < 1) return Die("--reads: a number from 1 up");
|
||||||
|
a.reads = n;
|
||||||
|
}
|
||||||
|
for (const std::string& part : Dyn::Split(a.models, ",")) {
|
||||||
|
const std::string kv = Dyn::Strip(part);
|
||||||
|
if (kv.empty()) continue;
|
||||||
|
const std::size_t equals = kv.find('=');
|
||||||
|
if (equals == std::string::npos) throw Dyn::Error("ValueError", "not enough values to unpack (expected 2, got 1)");
|
||||||
|
const std::string key = kv.substr(0, equals);
|
||||||
|
std::vector<Plan::ModelTier>& tiers = Plan::Models();
|
||||||
|
const auto tier = std::ranges::find_if(tiers, [&](const Plan::ModelTier& m) { return m.tier == key; });
|
||||||
|
if (tier == tiers.end()) {
|
||||||
|
std::vector<std::string> known;
|
||||||
|
for (const Plan::ModelTier& m : tiers) known.push_back(m.tier);
|
||||||
|
std::println(std::cerr, "--models: unknown key {} (known: {})", Dyn::Repr(key), Dyn::Join(known, ", "));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
tier->id = kv.substr(equals + 1);
|
||||||
|
}
|
||||||
|
return Run::Main(std::move(a), *config);
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr std::array<OptionSpec, 1> SlotsOptions = {{
|
||||||
|
{"--artifact", true, "ARTIFACT", "a frozen artifact dir: no more slots than its browser passes can use at once"},
|
||||||
|
}};
|
||||||
|
|
||||||
|
std::int32_t SlotsCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
|
||||||
|
constexpr std::string_view Prog = "crafter-review slots";
|
||||||
|
const Parsed parsed = ParseOptions(args, SlotsOptions);
|
||||||
|
if (parsed.help) {
|
||||||
|
std::print("{}", Help(Prog, "how many stacks (slots.*) fit in free memory, and the commands that bring up the missing ones", SlotsOptions));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, SlotsOptions), Prog, parsed.error);
|
||||||
|
const std::optional<Json> config = LoadConfig(configPath);
|
||||||
|
if (!config) return 3;
|
||||||
|
const std::optional<std::string> artifact = parsed.Value("artifact");
|
||||||
|
try {
|
||||||
|
Run::SlotsPlan(*config, fs::current_path(), artifact ? std::optional<fs::path>(*artifact) : std::nullopt);
|
||||||
|
} catch (const Inventory::InvariantError& e) {
|
||||||
|
return Die(e.what());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
std::int32_t FreezeCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
|
std::int32_t FreezeCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
|
||||||
constexpr std::string_view Prog = "crafter-review freeze";
|
constexpr std::string_view Prog = "crafter-review freeze";
|
||||||
const Parsed parsed = ParseOptions(args, FreezeOptions);
|
const Parsed parsed = ParseOptions(args, FreezeOptions);
|
||||||
|
|
@ -215,6 +407,10 @@ inventory unit, against a frozen commit range.)";
|
||||||
// command anyone types.
|
// command anyone types.
|
||||||
if (!rest.empty() && rest.front() == Process::SuperviseVerb) return Process::SuperviseMain(rest.subspan(1));
|
if (!rest.empty() && rest.front() == Process::SuperviseVerb) return Process::SuperviseMain(rest.subspan(1));
|
||||||
Platform::Init();
|
Platform::Init();
|
||||||
|
// Before any other thread: SIGINT, SIGTERM and SIGHUP go to a thread of
|
||||||
|
// their own, which stops a run once it is under way and otherwise ends
|
||||||
|
// the process.
|
||||||
|
Process::InstallSignalThread(Run::OnSignal);
|
||||||
// The options before the command: --config FILE or --config=FILE, and
|
// The options before the command: --config FILE or --config=FILE, and
|
||||||
// any unique prefix of a long option.
|
// any unique prefix of a long option.
|
||||||
std::optional<std::string> configPath;
|
std::optional<std::string> configPath;
|
||||||
|
|
@ -259,6 +455,8 @@ inventory unit, against a frozen commit range.)";
|
||||||
return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
||||||
}
|
}
|
||||||
if (command == "freeze") return FreezeCommand(after, configPath);
|
if (command == "freeze") return FreezeCommand(after, configPath);
|
||||||
|
if (command == "run") return RunCommand(after, configPath);
|
||||||
|
if (command == "slots") return SlotsCommand(after, configPath);
|
||||||
} catch (const Dyn::Error& error) {
|
} catch (const Dyn::Error& error) {
|
||||||
// An error no command catches: its message, exit 1.
|
// An error no command catches: its message, exit 1.
|
||||||
std::println(std::cerr, "crafter-review: {}: {}", error.Kind(), error.what());
|
std::println(std::cerr, "crafter-review: {}: {}", error.Kind(), error.what());
|
||||||
|
|
|
||||||
2171
implementations/Crafter.Review-Run.cpp
Normal file
2171
implementations/Crafter.Review-Run.cpp
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -319,6 +319,10 @@ namespace Crafter::Review::SeatPool {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fs::path& Ledger::Path() const {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the seats ---------------------------------------------------------------------
|
// ---- the seats ---------------------------------------------------------------------
|
||||||
Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret) {
|
Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
249
interfaces/Crafter.Review-Run.cppm
Normal file
249
interfaces/Crafter.Review-Run.cppm
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Review:Run;
|
||||||
|
import std;
|
||||||
|
import :Json;
|
||||||
|
import :Plan;
|
||||||
|
import :Pool;
|
||||||
|
import :SeatPool;
|
||||||
|
import :Memory;
|
||||||
|
import :PgWire;
|
||||||
|
import :State;
|
||||||
|
import :Platform;
|
||||||
|
|
||||||
|
// Run: the driver for a commit range about to be pushed
|
||||||
|
// (process/prepush.md). One claude process per inventory unit per lens,
|
||||||
|
// whole passes where a lens has one (each gating its block passes),
|
||||||
|
// fail-closed coverage per unit, the matrix, per-unit reuse across hashes, a
|
||||||
|
// seat pool.
|
||||||
|
//
|
||||||
|
// Gates: 1 repo-better-solution (approach) -> 2 repo-functional (the range
|
||||||
|
// exercised in the running application) -> 3 repo-bug-prover and
|
||||||
|
// repo-security -> 4 the owner. SEND-READY = push-ready. Exit codes: 0
|
||||||
|
// SEND-READY, 1 FIX FIRST, 2 INCOMPLETE / PARTIAL / CONFIRMING (gates clean,
|
||||||
|
// code units still owe clean reads - run the same line again), 3 preflight.
|
||||||
|
//
|
||||||
|
// A Run is built empty and filled field by field, as the tests fill one, so
|
||||||
|
// its attributes are public; Main builds and runs the real one.
|
||||||
|
export namespace Crafter::Review::Run {
|
||||||
|
using Log = std::function<void(const std::string&)>;
|
||||||
|
|
||||||
|
// `crafter-review run`'s options: the dump records them under `args`, in
|
||||||
|
// this order.
|
||||||
|
struct Arguments {
|
||||||
|
std::optional<std::string> config;
|
||||||
|
std::string mode = "commits";
|
||||||
|
std::string artifact;
|
||||||
|
std::optional<std::string> hash;
|
||||||
|
std::optional<std::string> dispositions;
|
||||||
|
std::string base;
|
||||||
|
std::string date;
|
||||||
|
std::optional<std::string> trees;
|
||||||
|
std::string contextPaths;
|
||||||
|
std::optional<std::string> only;
|
||||||
|
std::optional<std::string> without;
|
||||||
|
// --units, split on commas.
|
||||||
|
std::optional<std::set<std::string>> units;
|
||||||
|
bool warm = false;
|
||||||
|
std::int64_t concurrency = 128;
|
||||||
|
std::string effort;
|
||||||
|
std::int64_t reads = Plan::Reads;
|
||||||
|
bool noReuse = false;
|
||||||
|
bool noSalvage = false;
|
||||||
|
bool dryRun = false;
|
||||||
|
std::optional<std::string> claudeBin;
|
||||||
|
std::string models;
|
||||||
|
std::optional<std::string> scratch;
|
||||||
|
std::optional<std::string> seats;
|
||||||
|
std::optional<std::string> envFile;
|
||||||
|
bool noProbe = false;
|
||||||
|
std::optional<double> apiBudget;
|
||||||
|
std::string harness = "claude";
|
||||||
|
std::optional<std::string> variant;
|
||||||
|
std::optional<std::string> opencodeBin;
|
||||||
|
};
|
||||||
|
// vars(a) without `cmd`, the units sorted.
|
||||||
|
Json ArgsJson(const Arguments& a);
|
||||||
|
|
||||||
|
// die(): "preflight: <message>" on stderr, exit 3.
|
||||||
|
class Preflight : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
using std::runtime_error::runtime_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// (ok, detail): does the application under test answer at <url><path>?
|
||||||
|
// Below 500 is ok.
|
||||||
|
std::pair<bool, std::string> AppReady(std::string_view url, std::string_view path = "/", double timeout = 10);
|
||||||
|
|
||||||
|
// `slots`: how many stacks to bring up - as many as free memory holds,
|
||||||
|
// and no more than the range's browser passes can use at once - and the
|
||||||
|
// commands for the ones not up yet. (want, commands).
|
||||||
|
std::pair<std::int64_t, std::vector<std::string>> SlotsPlan(const Json& cfg, const std::filesystem::path& repo, const std::optional<std::filesystem::path>& artifact = std::nullopt,
|
||||||
|
const std::optional<std::filesystem::path>& scratchRoot = std::nullopt, const Log& log = {});
|
||||||
|
|
||||||
|
class Run {
|
||||||
|
public:
|
||||||
|
Run();
|
||||||
|
Run(Arguments a, Json cfg);
|
||||||
|
~Run();
|
||||||
|
Run(const Run&) = delete;
|
||||||
|
Run& operator=(const Run&) = delete;
|
||||||
|
|
||||||
|
// The whole run: the gates, the dump, the summary; the exit code.
|
||||||
|
std::int32_t Execute();
|
||||||
|
// A signal or `crafter-review stop`: every running pass killed, the
|
||||||
|
// pool aborted, and the stop stays in force for the gates after.
|
||||||
|
void Stop(const std::string& why, std::int32_t signal = 0);
|
||||||
|
// The file `crafter-review stop` writes, once the scratch dir is
|
||||||
|
// known.
|
||||||
|
std::optional<std::filesystem::path> StopFile() const;
|
||||||
|
// The handler a signal reaches: false before gate 0 set it up (the
|
||||||
|
// signal then ends the process).
|
||||||
|
bool Ready() const;
|
||||||
|
|
||||||
|
// ---- gate 0 and its preflights ---------------------------------------
|
||||||
|
void Gate0();
|
||||||
|
std::map<std::string, std::string> AppLenses(std::span<const std::string> lensFiles) const;
|
||||||
|
std::map<std::string, std::string> DbLenses(std::span<const std::string> lensFiles) const;
|
||||||
|
void InstrumentPreflight(std::span<const std::string> lensFiles);
|
||||||
|
static std::optional<std::string> MigrationVersion(std::string_view file);
|
||||||
|
// (problem, note): the migrations at the range HEAD against the ones
|
||||||
|
// the database applied.
|
||||||
|
std::pair<std::optional<std::string>, std::optional<std::string>> MigrationsDrift(const std::string& url);
|
||||||
|
std::unique_ptr<Pool::Stacks> LoadStacks();
|
||||||
|
std::string StacksLine() const;
|
||||||
|
std::vector<Json> DbStacks(std::span<const std::string> lensFiles) const;
|
||||||
|
void StackPreflight(std::span<const std::string> lensFiles);
|
||||||
|
std::optional<std::string> LoginMissing(const Json& stack) const;
|
||||||
|
std::optional<std::string> TreeDrift();
|
||||||
|
|
||||||
|
// ---- prompts ---------------------------------------------------------------
|
||||||
|
// (appended system prompt file, stdin user message) of a pass.
|
||||||
|
std::pair<std::filesystem::path, std::string> PromptFor(const Json& job);
|
||||||
|
std::optional<std::string> CacheKey(Json& job);
|
||||||
|
bool WarmProbe(const Json& job) const;
|
||||||
|
|
||||||
|
// ---- reuse -----------------------------------------------------------------
|
||||||
|
const Plan::CommitCode& RangeCode();
|
||||||
|
std::optional<Json> ReuseWhole(const std::string& name, Json& job);
|
||||||
|
std::optional<std::string> ReuseKey(const Json& job, const std::optional<std::string>& environment = std::nullopt);
|
||||||
|
std::vector<std::string> OtherKeys(const Json& job, const std::optional<std::string>& key);
|
||||||
|
std::optional<std::pair<Json, Json>> Salvage(const Json& job);
|
||||||
|
Json DirEnvironments(const std::filesystem::path& dir);
|
||||||
|
static std::int64_t DirRound(const std::filesystem::path& dir);
|
||||||
|
std::int64_t ReadsNeeded(const Json& job) const;
|
||||||
|
|
||||||
|
// ---- one process, a gate -------------------------------------------------
|
||||||
|
std::pair<Json, Json> ExecJob(const Json& job, std::int64_t attempt);
|
||||||
|
Json PartFrom(const Json& job, const Json& rec, Json raw);
|
||||||
|
static Json DryPart(const Json& job);
|
||||||
|
std::vector<Json> RunGate(std::string_view title, std::vector<std::string> lensFiles);
|
||||||
|
std::unique_ptr<Pool::Admission> Admission(std::span<const std::string> lensFiles);
|
||||||
|
std::optional<Json> Resumable(const std::string& name, const std::string& lensFile, std::string_view title);
|
||||||
|
void Checkpoint(std::string_view title);
|
||||||
|
std::vector<Json> PrepareBlocks(const std::string& name, std::vector<Json> blocks, std::map<std::string, std::vector<Json>>& parts);
|
||||||
|
|
||||||
|
// ---- the end ---------------------------------------------------------------
|
||||||
|
std::int32_t Finish(std::string overall, const Json& stoppedAt, Json incomplete);
|
||||||
|
Json BuildDump(std::string_view overall, const Json& stoppedAt, const Json& incomplete);
|
||||||
|
Json DryReport();
|
||||||
|
bool Storable(const Json& rec) const;
|
||||||
|
Json StoreState();
|
||||||
|
|
||||||
|
void Message(const std::string& message);
|
||||||
|
|
||||||
|
// ---- the run's state -----------------------------------------------------
|
||||||
|
Arguments a;
|
||||||
|
Json cfg = Json::object();
|
||||||
|
std::vector<std::string> off;
|
||||||
|
// {lens file: environment}, in the config's lens order: the dump, the
|
||||||
|
// resume file and the scratch dir's marker write it in that order.
|
||||||
|
Json environments = Json::object();
|
||||||
|
std::filesystem::path repo;
|
||||||
|
std::vector<std::string> logs;
|
||||||
|
double t0 = 0;
|
||||||
|
std::optional<std::filesystem::path> logFile;
|
||||||
|
std::vector<Json> records;
|
||||||
|
std::vector<Json> results;
|
||||||
|
std::map<std::string, std::int64_t> lensLayersBytes;
|
||||||
|
std::vector<Json> jobsRun;
|
||||||
|
std::map<std::string, std::string> summaries;
|
||||||
|
std::optional<std::string> abort;
|
||||||
|
Pool::Scheduler* sched = nullptr;
|
||||||
|
std::optional<Plan::CommitCode> commitCode;
|
||||||
|
std::int64_t x = 1;
|
||||||
|
std::int64_t round = 1;
|
||||||
|
bool newRound = false;
|
||||||
|
Json owed = Json::object();
|
||||||
|
std::map<std::string, Json> wholeParts;
|
||||||
|
std::optional<std::string> dbChecked;
|
||||||
|
std::optional<bool> dbBypass;
|
||||||
|
std::optional<std::string> dbRole;
|
||||||
|
std::unique_ptr<PgWire::Broker> broker;
|
||||||
|
std::unique_ptr<Pool::Stacks> stacks;
|
||||||
|
bool slotMode = false;
|
||||||
|
std::vector<std::string> slotProblems;
|
||||||
|
std::unique_ptr<SeatPool::Board> board;
|
||||||
|
std::unique_ptr<State::RunLock> runLock;
|
||||||
|
std::unique_ptr<Platform::LifetimeLock> lifetime;
|
||||||
|
std::vector<std::string> trees;
|
||||||
|
std::vector<std::string> contextPaths;
|
||||||
|
std::string hash;
|
||||||
|
std::string h8;
|
||||||
|
std::string head;
|
||||||
|
std::filesystem::path gateDir;
|
||||||
|
Json units = Json::array();
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
bool testMode = false;
|
||||||
|
std::filesystem::path scratch;
|
||||||
|
std::filesystem::path dumpPath;
|
||||||
|
std::vector<std::string> notReviewed;
|
||||||
|
std::vector<Json> dispEntries;
|
||||||
|
std::vector<Json> dispRows;
|
||||||
|
std::vector<Json> inForce;
|
||||||
|
std::filesystem::path statePath;
|
||||||
|
Json state = State::EmptyState();
|
||||||
|
std::vector<std::filesystem::path> salvageDirs;
|
||||||
|
std::filesystem::path resumePath;
|
||||||
|
Json resume;
|
||||||
|
std::map<std::string, std::string> charters;
|
||||||
|
std::map<std::string, std::string> charterSha;
|
||||||
|
std::string systemText;
|
||||||
|
// {sha: path}, in the order first written.
|
||||||
|
std::vector<std::pair<std::string, std::filesystem::path>> systemPaths;
|
||||||
|
// {lens: the shas of its system files}, lenses in the order first met.
|
||||||
|
std::vector<std::pair<std::string, std::set<std::string>>> systemByLens;
|
||||||
|
std::string runLayer;
|
||||||
|
std::optional<std::string> loginFixture;
|
||||||
|
std::string appBlock;
|
||||||
|
std::map<std::string, std::string> schemas;
|
||||||
|
std::unique_ptr<Memory::Calibration> calibration;
|
||||||
|
std::unique_ptr<Memory::Sampler> sampler;
|
||||||
|
std::unique_ptr<Pool::Runner> runner;
|
||||||
|
Json apiCfg = Json::object();
|
||||||
|
std::unique_ptr<SeatPool::Ledger> ledger;
|
||||||
|
std::vector<Json> seats;
|
||||||
|
std::map<std::filesystem::path, Json> envMarkers;
|
||||||
|
std::set<std::pair<std::string, std::string>> envSkips;
|
||||||
|
std::map<std::pair<std::string, std::string>, std::tuple<std::map<std::string, std::string>, std::vector<std::string>, std::map<std::string, std::string>>> migCache;
|
||||||
|
// The log every line goes to (the tests put their own here).
|
||||||
|
Log log;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// What exec_job's threads share with the pool's: the system files,
|
||||||
|
// the lens layers, the jobs run.
|
||||||
|
mutable std::recursive_mutex shared;
|
||||||
|
mutable std::mutex logLock;
|
||||||
|
std::optional<std::string> stopped;
|
||||||
|
std::atomic<bool> ready = false;
|
||||||
|
std::optional<std::filesystem::path> stopFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The `run` command: argv parsed by the CLI; the exit code.
|
||||||
|
std::int32_t Main(Arguments a, const Json& cfg);
|
||||||
|
// What SIGINT, SIGTERM and SIGHUP do (Process::InstallSignalThread):
|
||||||
|
// stop the run once gate 0 is through; before that, and for every other
|
||||||
|
// command, the process ends, its descendants with it.
|
||||||
|
void OnSignal(std::int32_t signal);
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,7 @@ export namespace Crafter::Review::SeatPool {
|
||||||
Json MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now = std::nullopt);
|
Json MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now = std::nullopt);
|
||||||
void Save();
|
void Save();
|
||||||
const Json& Data() const;
|
const Json& Data() const;
|
||||||
|
const std::filesystem::path& Path() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::filesystem::path path;
|
std::filesystem::path path;
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,5 @@ export import :SeatPool;
|
||||||
export import :OpenCode;
|
export import :OpenCode;
|
||||||
export import :Pool;
|
export import :Pool;
|
||||||
export import :Freeze;
|
export import :Freeze;
|
||||||
|
export import :Run;
|
||||||
export import :Driver;
|
export import :Driver;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ using namespace Crafter;
|
||||||
namespace {
|
namespace {
|
||||||
// The umbrella, then the partitions in import order: each after the ones
|
// The umbrella, then the partitions in import order: each after the ones
|
||||||
// it imports, which is also the order the module map in AGENTS.md uses.
|
// it imports, which is also the order the module map in AGENTS.md uses.
|
||||||
constexpr std::array<std::string_view, 30> Interfaces = {
|
constexpr std::array<std::string_view, 31> Interfaces = {
|
||||||
"interfaces/Crafter.Review",
|
"interfaces/Crafter.Review",
|
||||||
"interfaces/Crafter.Review-Unicode",
|
"interfaces/Crafter.Review-Unicode",
|
||||||
"interfaces/Crafter.Review-Json",
|
"interfaces/Crafter.Review-Json",
|
||||||
|
|
@ -43,9 +43,10 @@ namespace {
|
||||||
"interfaces/Crafter.Review-OpenCode",
|
"interfaces/Crafter.Review-OpenCode",
|
||||||
"interfaces/Crafter.Review-Pool",
|
"interfaces/Crafter.Review-Pool",
|
||||||
"interfaces/Crafter.Review-Freeze",
|
"interfaces/Crafter.Review-Freeze",
|
||||||
|
"interfaces/Crafter.Review-Run",
|
||||||
"interfaces/Crafter.Review-Driver",
|
"interfaces/Crafter.Review-Driver",
|
||||||
};
|
};
|
||||||
constexpr std::array<std::string_view, 27> Implementations = {
|
constexpr std::array<std::string_view, 28> Implementations = {
|
||||||
"implementations/Crafter.Review-Text",
|
"implementations/Crafter.Review-Text",
|
||||||
"implementations/Crafter.Review-Crypto",
|
"implementations/Crafter.Review-Crypto",
|
||||||
"implementations/Crafter.Review-Platform",
|
"implementations/Crafter.Review-Platform",
|
||||||
|
|
@ -72,6 +73,7 @@ namespace {
|
||||||
"implementations/Crafter.Review-OpenCode",
|
"implementations/Crafter.Review-OpenCode",
|
||||||
"implementations/Crafter.Review-Pool",
|
"implementations/Crafter.Review-Pool",
|
||||||
"implementations/Crafter.Review-Freeze",
|
"implementations/Crafter.Review-Freeze",
|
||||||
|
"implementations/Crafter.Review-Run",
|
||||||
"implementations/Crafter.Review-Driver",
|
"implementations/Crafter.Review-Driver",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ namespace {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
std::int32_t Run() {
|
std::int32_t Checks() {
|
||||||
std::println("several drivers at once: the board (seats, memory, stacks, the cap), one full run per hash, one driver per scratch dir, the merged unit-state, the locked ledger");
|
std::println("several drivers at once: the board (seats, memory, stacks, the cap), one full run per hash, one driver per scratch dir, the merged unit-state, the locked ledger");
|
||||||
Platform::SetMode(Platform::Mode::Native);
|
Platform::SetMode(Platform::Mode::Native);
|
||||||
const fs::path td = fs::temp_directory_path() / std::format("crafter-review-drivers-{}", Platform::PublishedPid());
|
const fs::path td = fs::temp_directory_path() / std::format("crafter-review-drivers-{}", Platform::PublishedPid());
|
||||||
|
|
@ -488,7 +488,7 @@ int main(int argc, char** argv) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return Run();
|
return Checks();
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::println(std::cerr, "drivers: {}", error.what());
|
std::println(std::cerr, "drivers: {}", error.what());
|
||||||
return 1;
|
return 1;
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ namespace {
|
||||||
return Dyn::Replace(text, td.string(), "<td>");
|
return Dyn::Replace(text, td.string(), "<td>");
|
||||||
}
|
}
|
||||||
|
|
||||||
std::int32_t Run() {
|
std::int32_t Checks() {
|
||||||
std::println("opencode harness + OpenRouter seats: loader, environments, permissions, event parsing, the tool schema");
|
std::println("opencode harness + OpenRouter seats: loader, environments, permissions, event parsing, the tool schema");
|
||||||
Platform::SetMode(Platform::Mode::Native);
|
Platform::SetMode(Platform::Mode::Native);
|
||||||
// The directory modes below are the generator's, made under 022.
|
// The directory modes below are the generator's, made under 022.
|
||||||
|
|
@ -221,7 +221,7 @@ int main(int argc, char** argv) {
|
||||||
const std::vector<std::string_view> args(argv, argv + argc);
|
const std::vector<std::string_view> args(argv, argv + argc);
|
||||||
if (args.size() > 1 && args[1] == Process::SuperviseVerb) return Process::SuperviseMain(std::span(args).subspan(2));
|
if (args.size() > 1 && args[1] == Process::SuperviseVerb) return Process::SuperviseMain(std::span(args).subspan(2));
|
||||||
try {
|
try {
|
||||||
return Run();
|
return Checks();
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::println(std::cerr, "opencode: {}", error.what());
|
std::println(std::cerr, "opencode: {}", error.what());
|
||||||
return 1;
|
return 1;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue