Crafter.Review/interfaces/Crafter.Review-Process.cppm
Jorijn van der Graaf 554a5ddd48
feat(pool): the pool - stacks, admission, the runner and the scheduler
The allowlists and the instrument checks (mcp_connected, db_answered,
exercised), the stack keys, Stacks and Admission, the Runner that runs one
claude or opencode process per pass, and the Scheduler with its retries,
rate-window pauses, the prompt-prefix warm-up, the seats and the board.
One thread per pass; each worker runs on its own copy of the job.

Every pass runs under the supervisor, so a timeout or an abort takes its
whole tree down; a timed-out pass reports exit -9, a SIGKILLed child's
code. The helpers the lenses run are this binary's `sql` and `totp`.
:Process gains an Append stream for the OpenCode meta line.

The tests cover the pool and its share of the commits-mode, seat-pool and
several-drivers checks, a fake claude under the supervisor included.

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

118 lines
5.6 KiB
C++

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Review:Process;
import std;
// Child processes, and making sure none outlives what it was started for.
//
// SIGKILL to the claude process alone would leave the Playwright server and
// Chromium under it running after a timeout, and every pass running after a
// SIGKILLed driver. So:
// - every child is started from one long-lived spawner thread, because
// PR_SET_PDEATHSIG fires when the *thread* that forked the child ends;
// - the driver is a child subreaper, so whatever its children leave
// behind becomes its own child rather than init's, and one reaper thread
// owns waitpid(-1) for all of them;
// - a claude pass runs under a supervisor — this binary again, as
// `crafter-review __supervise <argv>` — that is the subreaper of the
// pass's whole tree. Playwright starts Chromium detached, as the leader of
// a process group of its own, so killing a process group would miss it; a
// tree walk does not. The supervisor kills the tree when claude exits, on
// SIGTERM (a timeout or an abort), and when the driver dies however it
// dies, through PR_SET_PDEATHSIG.
//
// Every subprocess goes through Start(); popen() and system() are not used.
export namespace Crafter::Review::Process {
// Where a child's standard stream goes.
struct Stream {
enum class Kind {
Inherit,
Null,
// Opened for reading (stdin) or created and truncated (stdout,
// stderr).
File,
// stdout or stderr after what the file already holds: a line the
// driver wrote before the child starts stays first.
Append,
// A pipe whose other end Start() returns.
Pipe,
};
Kind kind = Kind::Inherit;
std::filesystem::path file;
};
struct Spec {
// argv[0] is looked up on the child's PATH when it has no '/'.
std::vector<std::string> argv;
// nullopt: this process's environment.
std::optional<std::map<std::string, std::string>> environment;
std::optional<std::filesystem::path> cwd;
Stream input;
Stream output;
Stream error;
// Sent to the child when this process ends (9, SIGKILL; a supervisor
// is started with 15, SIGTERM, so it can take its tree down first).
std::int32_t deathSignal = 9;
};
struct Child {
std::int64_t pid = 0;
// Our ends of the pipes the spec asked for, -1 otherwise; the caller
// closes them.
std::int32_t input = -1;
std::int32_t output = -1;
std::int32_t error = -1;
};
// Throws std::runtime_error when the child cannot be started, with the
// errno text: "[Errno 2] No such file or directory: 'claude'".
Child Start(const Spec& spec);
// The return code once the child has exited — its exit status, or minus
// the signal that killed it — or nullopt when `timeout` ran out first, the
// child still running.
std::optional<std::int32_t> Wait(std::int64_t pid, std::optional<std::chrono::milliseconds> timeout = std::nullopt);
void Signal(std::int64_t pid, std::int32_t signal);
// Every descendant of `root` (and root itself with includeRoot) is
// stopped with SIGSTOP, the tree is scanned again until no new process
// appears — a stopped process cannot fork — and then all are killed.
void KillTree(std::int64_t root, bool includeRoot);
// KillTree of this process, at exit: never pid 1, never a process this
// process did not start, such as a debug shell in the same sandbox.
void KillDescendants();
// A command run to its end with both outputs captured: stdin from
// `input`, or /dev/null; both outputs read as they come, so neither can
// fill its pipe and stall the child. With a timeout the child is killed
// when it runs out, and timedOut is set; a supervised command (Supervised)
// gets SIGTERM, so the supervisor takes its tree down, and SIGKILL 10 s
// later if it is still there.
struct Captured {
std::int32_t returnCode = -1;
std::string out;
std::string err;
bool timedOut = false;
};
Captured Run(const std::vector<std::string>& argv, const std::optional<std::filesystem::path>& cwd = std::nullopt,
std::optional<std::chrono::milliseconds> timeout = std::nullopt, std::string_view input = {},
const std::optional<std::map<std::string, std::string>>& environment = std::nullopt);
// Main() calls both first, before any other thread starts. SIGINT,
// SIGTERM and SIGHUP are blocked in every thread and handed to `onSignal`
// on a thread of their own; SIGPIPE is ignored, so a helper that goes away
// mid-call cannot end a run that has been going for hours. Children get
// both back at their defaults.
void InstallSignalThread(std::function<void(std::int32_t)> onSignal);
void BecomeSubreaper();
constexpr std::string_view SuperviseVerb = "__supervise";
// The argv that runs `argv` under a supervisor: this binary, the verb,
// then `argv`.
std::vector<std::string> Supervised(std::span<const std::string> argv);
// The supervisor itself, given everything after the verb. Exits with the
// target's status, or 128 + the signal that killed it.
std::int32_t SuperviseMain(std::span<const std::string_view> argv);
// A supervisor's exit code as the target's return code: 128 + n up to
// 128 + 64 is "killed by signal n", -n.
std::int32_t SupervisedReturnCode(std::int32_t supervisorCode);
}