For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: [target.<triple>].runner is honoured on every target, a spawn failure is never silent, mcpp test reports unrunnable artifacts as not-run with exit 2, [xlings] values can be given per host platform, and the whole change ships as one PR, one release, and one sandbox verification.
Architecture: choose_runner stays the single read point and stops gating on os == none; the launcher layer carries the spawn errno up instead of re-spawning; the manifest resolves [xlings] per-platform values against the host at load time so every downstream reader keeps seeing a flat list. Design: .agents/docs/2026-09-02-runner-beyond-baremetal-design.md (sections 0 and 4 are normative).
Tech Stack: C++23 modules (mcpp builds itself with mcpp build), gtest unit tests under tests/unit/ run by mcpp test, bash e2e under tests/e2e/ run by tests/e2e/run_all.sh or individually with MCPP=<bin> bash tests/e2e/<n>.sh.
- Baseline is
origin/mainataeba151(version2026.9.1.1). Work in a fresh worktree; never switch branches in the shared checkout. - All code goes through one PR with squash merge; CI must be green on the PR and on
origin/mainafter merge. - Prose in docs and the PR is English academic register, declarative, no emoji, no question headings (
.agents/skills/mcpp-docs-style/SKILL.md). Everydocs/*.mdchange has adocs/zh/twin; CI enforces the pair (.github/tools/check_docs_style.sh). - New e2e scripts carry no
# requires:guard beyondunix-shell; both CI shards lack llvm and arequiresskip exits 0. - Exit code 2 means "could not start"; 1 keeps meaning "ran and failed"; 0 means every test ran and passed.
- Platform keys accepted in
[xlings]values:linux,macos(aliasmacosx),windows,default. Any other key is a hard manifest error. - Version bump:
mcpp.toml[package].versionandmodules/versioning/src/version.cppmMCPP_VERSIONin the same commit;.xlings.jsonbootstrap pin stays at the released version until the new release is indexed.
Files: none modified.
- Step 1: Create the worktree from
origin/main
cd ~/workspace/github/mcpp-community/mcpp
git fetch origin
git worktree add ../mcpp-issue544 -b fix/runner-hosted-targets origin/main
cd ../mcpp-issue544
git log --oneline -1 # must be aeba151- Step 2: Build the engine and run the unit suite once
mcpp self config --mirror CN
mcpp build 2>&1 | tail -3
mcpp test unit/test_process_run_exec 2>&1 | tail -3
mcpp test unit/test_manifest 2>&1 | tail -3Expected: both ok. The built binary is target/<triple>/<fp>/bin/mcpp; record its path as $MCPP for the e2e runs below (MCPP=$(ls -d target/*/*/bin/mcpp | head -1)).
Files:
- Modify:
modules/platform/src/unix/bounded_process.cppm:59-66(struct),:227-230(spawn site) - Modify:
modules/platform/src/windows/bounded_process.cppm:67-77(struct), theCreateProcessfailure site - Modify:
modules/platform/src/process.cppm—run_exec(:552-577),capture_exec(:580-625),BoundedOutcome(:661-666),dispatch_bounded(:707-726),run_exec_deadline(:731-750),capture_exec_deadline(:840-862), and the exported declarations near:110and:175 - Test:
tests/unit/test_process_run_exec.cpp
Interfaces:
-
Produces: every launcher gains a trailing
int* spawn_error = nullptr. Contract: when the child could not be spawned, the launcher returns 127 (orexit_code = 127inRunResult) and (a) ifspawn_erroris non-null, stores the errno there and prints nothing; (b) if null, printsspawn_failure(argv.front(), err)to stderr (run_exec) or intooutput(capture_exec).DeadlineRunandBoundedOutcomegainint spawn_error = 0;supported == false && spawn_error != 0means "spawned attempted and refused" and the deadline wrappers do not fall back in that case. -
Step 1: Write the failing tests
Append to tests/unit/test_process_run_exec.cpp inside the #if !defined(_WIN32) block:
TEST(RunExec, MissingProgramReportsOnStderrWhenCallerDoesNotAsk) {
testing::internal::CaptureStderr();
int rc = process::run_exec({"/no/such/program/mcpp-run-xyz"});
auto err = testing::internal::GetCapturedStderr();
EXPECT_EQ(rc, 127);
EXPECT_NE(err.find("/no/such/program/mcpp-run-xyz"), std::string::npos) << err;
EXPECT_NE(err.find("error 2"), std::string::npos) << err;
}
TEST(RunExec, MissingProgramTypesErrnoWhenCallerAsks) {
int spawnErr = 0;
testing::internal::CaptureStderr();
int rc = process::run_exec({"/no/such/program/mcpp-run-xyz"}, {}, &spawnErr);
auto err = testing::internal::GetCapturedStderr();
EXPECT_EQ(rc, 127);
EXPECT_EQ(spawnErr, ENOENT);
EXPECT_TRUE(err.empty()) << err; // the caller owns the report
}
TEST(RunExec, UnloadableArtifactIsENOEXEC) {
// A file with the executable bit and no loader magic: the kernel refuses
// it with ENOEXEC. posix_spawnp does not retry through /bin/sh the way
// execvp does, so the errno reaches the caller untouched.
auto dir = std::filesystem::temp_directory_path() / "mcpp-enoexec-test";
std::filesystem::create_directories(dir);
auto f = dir / "not-an-elf";
{ std::ofstream o(f); o << "\x7f" "NOT"; }
std::filesystem::permissions(f, std::filesystem::perms::owner_all);
int spawnErr = 0;
int rc = process::run_exec({f.string()}, {}, &spawnErr);
EXPECT_EQ(rc, 127);
EXPECT_EQ(spawnErr, ENOEXEC);
}
TEST(RunExecDeadline, SpawnFailureIsTypedAndNotRetried) {
int spawnErr = 0;
bool timedOut = false;
testing::internal::CaptureStderr();
int rc = process::run_exec_deadline({"/no/such/program/mcpp-dl-xyz"}, {},
std::chrono::milliseconds(5000), &timedOut,
&spawnErr);
auto err = testing::internal::GetCapturedStderr();
EXPECT_EQ(rc, 127);
EXPECT_EQ(spawnErr, ENOENT);
EXPECT_FALSE(timedOut);
EXPECT_TRUE(err.empty()) << err;
}
TEST(CaptureExecDeadline, SpawnFailureIsTypedInOutcome) {
int spawnErr = 0;
bool timedOut = false;
auto r = process::capture_exec_deadline({"/no/such/program/mcpp-cdl-xyz"}, {},
std::chrono::milliseconds(5000), &timedOut,
{}, &spawnErr);
EXPECT_EQ(r.exit_code, 127);
EXPECT_EQ(spawnErr, ENOENT);
EXPECT_TRUE(r.output.empty()) << r.output;
}Add #include <cerrno> and #include <fstream> at the top of the file.
- Step 2: Run to verify they fail
Run: mcpp build && mcpp test unit/test_process_run_exec
Expected: compile failure on the new spawn_error parameter (the tests name a signature that does not exist yet).
- Step 3: Implement
modules/platform/src/unix/bounded_process.cppm, struct:
struct DeadlineRun {
bool supported = false;
int exit_code = 0;
bool timed_out = false;
// Non-zero when `supported` is false BECAUSE the spawn was attempted and
// refused (the errno posix_spawnp returned). Zero with `supported` false
// means the platform has no bounded launcher at all. The two need
// opposite treatment upstream: the first is reported, the second falls
// back to the unbounded launcher.
int spawn_error = 0;
};Spawn site (:230): if (sp != 0) { out.spawn_error = sp; if (capture) ::close(fds[0]); return out; }.
modules/platform/src/windows/bounded_process.cppm: same field; at the CreateProcessW failure site set out.spawn_error = static_cast<int>(::GetLastError());.
modules/platform/src/process.cppm:
struct BoundedOutcome {
bool supported = false;
int exit_code = 0;
bool timed_out = false;
int spawn_error = 0;
std::string output;
};
// dispatch_bounded: copy r.spawn_error into outcome.spawn_error on both branches.
int run_exec(const std::vector<std::string>& argv,
const std::vector<std::pair<std::string, std::string>>& extraEnv,
int* spawn_error)
{
if (spawn_error) *spawn_error = 0;
if (argv.empty()) return 127;
#if defined(__linux__) || defined(__APPLE__)
...
pid_t pid = 0;
if (int sp = ::posix_spawnp(&pid, cargv[0], nullptr, nullptr, cargv.data(), envp.data());
sp != 0) {
// A spawn failure is reported exactly once: by the caller when it
// asked to see the errno, and here otherwise. It is never dropped —
// "Running …", a blank line and exit 1 was the whole of #544's output.
if (spawn_error) *spawn_error = sp;
else std::fputs(spawn_failure(argv.front(), sp).c_str(), stderr);
return 127;
}
...
#else
// std::system cannot distinguish a refused launch from a failed child;
// spawn_error stays 0 and the shell's own message is what the user sees.
...
#endif
}capture_exec: same shape; when spawn_error is non-null store and leave output empty, else format spawn_failure into output as today.
run_exec_deadline and capture_exec_deadline:
auto r = dispatch_bounded(...);
if (!r.supported) {
if (r.spawn_error != 0) {
// Attempted and refused. Spawning again would only discard this
// errno and pay for a second refusal.
if (spawn_error) *spawn_error = r.spawn_error;
else std::fputs(spawn_failure(argv.front(), r.spawn_error).c_str(), stderr);
return 127; // capture variant: result.exit_code = 127
}
return run_exec(argv, extraEnv, spawn_error); // no bounded launcher here
}Update the exported declarations (process.cppm:110, :175, and the run_exec/capture_exec declarations) with the trailing int* spawn_error = nullptr. run_shell_deadline is unchanged.
- Step 4: Run to verify they pass
Run: mcpp build && mcpp test unit/test_process_run_exec
Expected: ok. with the five new tests listed.
- Step 5: Commit
git add modules/platform tests/unit/test_process_run_exec.cpp
git commit -m "platform: carry the spawn errno up instead of re-spawning; run_exec is never silent (#544)"Files:
- Modify:
modules/manifest/src/toml.cppm:1350-1361([xlings]),:1917-1959(sweep) - Modify:
modules/manifest/src/types.cppm:758-775(XlingsConfig, comment only) - Test:
tests/unit/test_manifest.cpp
Interfaces:
-
Produces:
mcpp::manifest::resolve_host_value(const toml::Value&, std::string_view hostPlatform) -> std::expected<std::optional<std::string>, std::string>exported from the manifest module. Returns the string,nulloptwhen the value is a platform table with no matching key and nodefault, and an error string for a table with an unknown key or a non-string leaf.mcpp::manifest::host_platform_key()returns"linux","macos"or"windows"for the running binary.XlingsConfig::depsand::workspacekeep their flat types; resolution happens at load. -
Step 1: Write the failing tests
Append to tests/unit/test_manifest.cpp (follow the file's fixture helper for writing a manifest to a temp dir and calling mcpp::manifest::load; the helper used at :3249 is the model):
TEST(Manifest, TargetSweepReportsArrayTyposAndListsRunner) {
constexpr auto src = R"(
[package]
name = "app"
version = "0.1.0"
[target.aarch64-linux-musl]
runnerX = ["qemu-aarch64-static"]
)";
auto m = load_from_string(src);
ASSERT_TRUE(m.has_value()) << m.error().message;
ASSERT_EQ(m->schemaWarnings.size(), 1u);
EXPECT_NE(m->schemaWarnings[0].find("'runnerX'"), std::string::npos);
EXPECT_NE(m->schemaWarnings[0].find("runner"), std::string::npos);
EXPECT_NE(m->schemaWarnings[0].find("Supported keys: cxx_runtime, linkage, runner, sysroot, toolchain"),
std::string::npos) << m->schemaWarnings[0];
}
TEST(Manifest, XlingsDepsAcceptPerPlatformEntries) {
constexpr auto src = R"(
[package]
name = "app"
version = "0.1.0"
[xlings]
deps = ["xim:ninja", { linux = "qemu-user-aarch64" }, { windows = "nasm", default = "yasm" }]
)";
auto m = load_from_string(src);
ASSERT_TRUE(m.has_value()) << m.error().message;
const auto host = mcpp::manifest::host_platform_key();
std::vector<std::string> want{"xim:ninja"};
if (host == "linux") want.push_back("qemu-user-aarch64");
want.push_back(host == "windows" ? "nasm" : "yasm");
EXPECT_EQ(m->xlings.deps, want);
}
TEST(Manifest, XlingsWorkspaceAcceptsPerPlatformValues) {
constexpr auto src = R"(
[package]
name = "app"
version = "0.1.0"
[xlings.workspace]
gcc = { linux = "15.1.0" }
llvm = { macos = "20", default = "22" }
)";
auto m = load_from_string(src);
ASSERT_TRUE(m.has_value()) << m.error().message;
const auto host = mcpp::manifest::host_platform_key();
if (host == "linux") EXPECT_EQ(m->xlings.workspace.at("gcc"), "15.1.0");
else EXPECT_EQ(m->xlings.workspace.count("gcc"), 0u);
EXPECT_EQ(m->xlings.workspace.at("llvm"), host == "macos" ? "20" : "22");
}
TEST(Manifest, XlingsUnknownPlatformKeyIsAnError) {
constexpr auto src = R"(
[package]
name = "app"
version = "0.1.0"
[xlings]
deps = [{ linxu = "qemu-user-aarch64" }]
)";
auto m = load_from_string(src);
ASSERT_FALSE(m.has_value());
EXPECT_NE(m.error().message.find("linxu"), std::string::npos) << m.error().message;
EXPECT_NE(m.error().message.find("linux, macos, windows, default"), std::string::npos)
<< m.error().message;
}
TEST(Manifest, ResolveHostValueTable) {
using mcpp::manifest::resolve_host_value;
auto d = mcpp::libs::toml::parse(R"(v = { linux = "a", macosx = "b", default = "c" })");
ASSERT_TRUE(d.has_value());
const auto& v = d->root().at("v");
EXPECT_EQ(resolve_host_value(v, "linux").value().value(), "a");
EXPECT_EQ(resolve_host_value(v, "macos").value().value(), "b"); // macosx alias
EXPECT_EQ(resolve_host_value(v, "windows").value().value(), "c");
auto e = mcpp::libs::toml::parse(R"(v = { linux = "a" })");
EXPECT_FALSE(resolve_host_value(e->root().at("v"), "windows").value().has_value());
}If test_manifest.cpp has no load_from_string helper, add one at the top of the file that writes src to <temp>/mcpp.toml and calls mcpp::manifest::load(path); model it on the fixture at :3240-3250.
- Step 2: Run to verify they fail
Run: mcpp build && mcpp test unit/test_manifest
Expected: compile failure (host_platform_key, resolve_host_value undefined).
- Step 3: Implement
In modules/manifest/src/toml.cppm, before load:
// The host platform in the vocabulary xlings' `.xlings.json` uses for a
// per-platform value, spelled with mcpp's OS names. `[xlings]` describes the
// environment of the machine mcpp runs on, so it is resolved against that
// machine when the manifest is loaded; every downstream reader keeps seeing a
// flat list, which is what keeps the provisioning pass, its stamp and the
// build-program hand-off unchanged.
export std::string_view host_platform_key() {
#if defined(_WIN32)
return "windows";
#elif defined(__APPLE__)
return "macos";
#else
return "linux";
#endif
}
// A value that is either a string or a table keyed by platform, as xlings'
// `workspace` allows: `"20"` or `{ linux = "15.1.0", default = "22" }`.
// `macosx` is accepted as xlings' own spelling of `macos`. A table with no
// entry for this host and no `default` resolves to nullopt, which the caller
// treats as "not declared on this host" — the same rule xlings applies.
// An unknown key is an error, not a dropped entry: a typo'd platform that
// silently declared nothing is the shape #531 was filed for.
export std::expected<std::optional<std::string>, std::string>
resolve_host_value(const mcpp::libs::toml::Value& v, std::string_view host) {
if (v.is_string()) return std::optional<std::string>{v.as_string()};
if (!v.is_table())
return std::unexpected("expected a string or a { <platform> = \"...\" } table");
static constexpr std::string_view kKnown[] = {"linux", "macos", "macosx", "windows", "default"};
std::optional<std::string> chosen, fallback;
for (auto& [k, val] : v.as_table()) {
bool known = false;
for (auto n : kKnown) if (k == n) { known = true; break; }
if (!known)
return std::unexpected(std::format(
"unknown platform key '{}'; expected one of linux, macos, windows, default", k));
if (!val.is_string())
return std::unexpected(std::format("platform key '{}' must be a string", k));
std::string_view canon = (k == "macosx") ? "macos" : std::string_view(k);
if (canon == host) chosen = val.as_string();
else if (canon == "default") fallback = val.as_string();
}
if (chosen) return chosen;
return fallback; // may be nullopt: not declared on this host
}Replace the [xlings] block at :1350-1361:
// [xlings] — build environment (L-1). Subsections mirror .xlings.json 1:1,
// including its per-platform value form, resolved here for this host.
if (auto* arr = doc->get("xlings.deps"); arr && arr->is_array()) {
std::size_t i = 0;
for (auto& el : arr->as_array()) {
auto r = resolve_host_value(el, host_platform_key());
if (!r) return std::unexpected(error(origin,
std::format("[xlings] deps[{}]: {}", i, r.error())));
if (*r) m.xlings.deps.push_back(**r);
++i;
}
}
... subos unchanged ...
if (auto* wt = doc->get_table("xlings.workspace"))
for (auto& [k, val] : *wt) {
auto r = resolve_host_value(val, host_platform_key());
if (!r) return std::unexpected(error(origin,
std::format("[xlings.workspace] {}: {}", k, r.error())));
if (*r) m.xlings.workspace[k] = **r;
}(doc->get returns the raw value; check the mcpp.libs.toml accessor names in modules/libs/toml before writing — get, get_table, as_array, is_table are the names used elsewhere in this file.)
Sweep at :1936-1959:
static constexpr std::string_view kKnownTargetScalars[] = {
"cxx_runtime", "linkage", "sysroot", "toolchain",
};
static constexpr std::string_view kKnownTargetArrays[] = { "runner" };
for (auto& [key, value] : body) {
if (value.is_table()) continue; // the conditional channel
const auto& known = value.is_array()
? std::span<const std::string_view>(kKnownTargetArrays)
: std::span<const std::string_view>(kKnownTargetScalars);
if (std::ranges::find(known, key) != known.end()) continue;
// The list names every key this table reads, arrays included,
// in one alphabetical line: a reader of the warning should not
// have to know which type a key is to find it here.
m.schemaWarnings.push_back(std::format(
"[target.{}] has unsupported key '{}' (ignored). Supported keys: "
"cxx_runtime, linkage, runner, sysroot, toolchain. "
"Per-role contracts go in [build].cxx_runtime's table form.",
triple, key));
}Update the comment block above it: the sweep now covers scalars and arrays; sub-tables remain excluded for the reason already recorded.
types.cppm:763: comment on deps — "resolved for this host at load; see resolve_host_value".
- Step 4: Run to verify they pass
Run: mcpp build && mcpp test unit/test_manifest
Expected: ok. Also run the existing EveryBuildKeyTheParserReadsIsAccepted-style negative controls; they must stay green.
- Step 5: Commit
git add modules/manifest tests/unit/test_manifest.cpp
git commit -m "manifest: [xlings] values per host platform; the target sweep reports array typos and lists runner (#544)"Files:
- Create:
src/build/runner_lookup.cppm(modulemcpp.build.runner_lookup) - Modify:
src/build/prepare.cppm:637-720(BuildContextgainsxlingsDepBinDirs), and the site afterfillXpkgDirs(:4549) that has bothctxand the payload directories - Test:
tests/unit/test_runner_lookup.cpp
Interfaces:
- Produces:
export namespace mcpp::build::runner_lookup {
struct Lookup {
std::optional<std::filesystem::path> program; // absolute, executable
std::vector<std::filesystem::path> searched; // in order, for the message
};
// argv0 absolute or containing a separator: taken as-is when executable.
// Otherwise: <each depBinDir>/argv0, then each PATH entry (pathEnv split on
// the platform separator), first executable regular file wins.
Lookup locate(std::string_view argv0,
std::span<const std::filesystem::path> depBinDirs,
std::string_view pathEnv);
enum class SpawnClass { Unloadable, Other };
// ENOEXEC (and EBADARCH on macOS, when defined) → Unloadable; anything else → Other.
SpawnClass classify(int spawnErrno);
std::string not_found_message(std::string_view triple, std::string_view argv0,
std::span<const std::filesystem::path> searched);
std::string spawn_failed_message(std::string_view program, int spawnErrno);
std::string unrunnable_message(std::string_view triple, const std::filesystem::path& artifact,
int spawnErrno);
}BuildContext::xlingsDepBinDirs is std::vector<std::filesystem::path>: <payload>/bin for each installed [xlings] deps payload of the runtime-owner manifest, declaration order.
- Step 1: Write the failing tests (
tests/unit/test_runner_lookup.cpp)
#include <gtest/gtest.h>
#include <cerrno>
#include <fstream>
import std;
import mcpp.build.runner_lookup;
using namespace mcpp::build::runner_lookup;
#if !defined(_WIN32)
namespace {
std::filesystem::path make_exe(const std::filesystem::path& dir, std::string_view name) {
std::filesystem::create_directories(dir);
auto p = dir / name;
{ std::ofstream o(p); o << "#!/bin/sh\nexit 0\n"; }
std::filesystem::permissions(p, std::filesystem::perms::owner_all);
return p;
}
}
TEST(RunnerLookup, PayloadBinBeatsPath) {
auto root = std::filesystem::temp_directory_path() / "mcpp-runner-lookup";
std::filesystem::remove_all(root);
auto inPayload = make_exe(root / "payload" / "bin", "qemu-x");
auto onPath = make_exe(root / "path", "qemu-x");
std::vector<std::filesystem::path> bins{root / "payload" / "bin"};
auto l = locate("qemu-x", bins, (root / "path").string());
ASSERT_TRUE(l.program.has_value());
EXPECT_EQ(*l.program, inPayload);
}
TEST(RunnerLookup, PathIsSearchedAfterPayloads) {
auto root = std::filesystem::temp_directory_path() / "mcpp-runner-lookup2";
std::filesystem::remove_all(root);
auto onPath = make_exe(root / "path", "qemu-y");
std::vector<std::filesystem::path> bins{root / "payload" / "bin"}; // absent dir
auto l = locate("qemu-y", bins, (root / "path").string());
ASSERT_TRUE(l.program.has_value());
EXPECT_EQ(*l.program, onPath);
ASSERT_EQ(l.searched.size(), 2u);
EXPECT_EQ(l.searched[0], root / "payload" / "bin");
}
TEST(RunnerLookup, NotFoundListsEveryDirectorySearched) {
auto root = std::filesystem::temp_directory_path() / "mcpp-runner-lookup3";
std::vector<std::filesystem::path> bins{root / "a" / "bin"};
auto l = locate("nope", bins, (root / "p1").string() + ":" + (root / "p2").string());
EXPECT_FALSE(l.program.has_value());
ASSERT_EQ(l.searched.size(), 3u);
auto msg = not_found_message("aarch64-linux-musl", "nope", l.searched);
EXPECT_NE(msg.find("'nope'"), std::string::npos) << msg;
EXPECT_NE(msg.find((root / "p2").string()), std::string::npos) << msg;
EXPECT_NE(msg.find("[xlings]"), std::string::npos) << msg;
}
TEST(RunnerLookup, AbsoluteArgv0IsTakenAsIs) {
auto root = std::filesystem::temp_directory_path() / "mcpp-runner-lookup4";
auto abs = make_exe(root, "runner.sh");
auto l = locate(abs.string(), {}, "");
ASSERT_TRUE(l.program.has_value());
EXPECT_EQ(*l.program, abs);
}
TEST(RunnerLookup, ClassifiesENOEXECAsUnloadable) {
EXPECT_EQ(classify(ENOEXEC), SpawnClass::Unloadable);
EXPECT_EQ(classify(EACCES), SpawnClass::Other);
EXPECT_EQ(classify(ENOENT), SpawnClass::Other);
}
TEST(RunnerLookup, UnrunnableMessageNamesKernelAnswerTripleAndKey) {
auto msg = unrunnable_message("aarch64-linux-musl", "/x/bin/app", ENOEXEC);
EXPECT_NE(msg.find("Exec format error"), std::string::npos) << msg;
EXPECT_NE(msg.find("aarch64-linux-musl"), std::string::npos);
EXPECT_NE(msg.find("[target.aarch64-linux-musl]"), std::string::npos);
EXPECT_NE(msg.find("runner = [\"qemu-aarch64-static\"]"), std::string::npos) << msg;
EXPECT_NE(msg.find("--no-runner"), std::string::npos);
}
#endif- Step 2: Run to verify they fail
Run: mcpp build && mcpp test unit/test_runner_lookup
Expected: build error, module mcpp.build.runner_lookup does not exist.
- Step 3: Implement
src/build/runner_lookup.cppm
// mcpp.build.runner_lookup — where the runner's program is, and what to say
// when it is not.
//
// The lookup is mcpp's own rather than posix_spawnp's for one measured reason:
// a bare name on PATH resolves to an xvm shim, and the shim answers for the
// current subos rather than for the package (e2e 130 in CI; `python3` on this
// machine). The payload's bin/ is the binary itself, so it is searched first.
// Doing the lookup here also splits "not found anywhere" (decided before any
// spawn) from a spawn-time ENOENT, which can then only mean "found, but its
// interpreter or loader is missing".
export module mcpp.build.runner_lookup;
import std;
import mcpp.platform;
export namespace mcpp::build::runner_lookup {
struct Lookup {
std::optional<std::filesystem::path> program;
std::vector<std::filesystem::path> searched;
};
namespace detail {
inline bool executable_file(const std::filesystem::path& p) {
std::error_code ec;
if (!std::filesystem::is_regular_file(p, ec)) return false;
auto perms = std::filesystem::status(p, ec).permissions();
using P = std::filesystem::perms;
return (perms & (P::owner_exec | P::group_exec | P::others_exec)) != P::none;
}
}
inline Lookup locate(std::string_view argv0,
std::span<const std::filesystem::path> depBinDirs,
std::string_view pathEnv)
{
Lookup out;
std::filesystem::path a0(argv0);
if (a0.is_absolute() || argv0.find('/') != std::string_view::npos
|| argv0.find('\\') != std::string_view::npos) {
if (detail::executable_file(a0)) out.program = std::filesystem::absolute(a0);
out.searched.push_back(a0.parent_path());
return out;
}
for (auto const& d : depBinDirs) {
out.searched.push_back(d);
if (auto c = d / a0; detail::executable_file(c)) { out.program = c; return out; }
}
constexpr char sep = mcpp::platform::is_windows ? ';' : ':';
for (auto part : std::views::split(pathEnv, sep)) {
std::filesystem::path d(std::string_view(part.begin(), part.end()));
if (d.empty()) continue;
out.searched.push_back(d);
if (auto c = d / a0; detail::executable_file(c)) { out.program = c; return out; }
}
return out;
}
enum class SpawnClass { Unloadable, Other };
inline SpawnClass classify(int e) {
if (e == ENOEXEC) return SpawnClass::Unloadable;
#if defined(EBADARCH)
if (e == EBADARCH) return SpawnClass::Unloadable;
#endif
return SpawnClass::Other;
}
inline std::string errno_text(int e) { return std::generic_category().message(e); }
inline std::string not_found_message(std::string_view triple, std::string_view argv0,
std::span<const std::filesystem::path> searched) {
std::string dirs;
for (auto const& d : searched) dirs += "\n " + d.string();
return std::format(
"runner '{}' for '{}' was not found. Searched:{}\n"
" Declare the package that provides it under [xlings] deps, or "
"install it on PATH.\n"
" Pass --no-runner to execute the artifact directly on this host.",
argv0, triple, dirs);
}
inline std::string spawn_failed_message(std::string_view program, int e) {
return std::format("runner '{}' could not be started: {} (error {})",
program, errno_text(e), e);
}
inline std::string unrunnable_message(std::string_view triple,
const std::filesystem::path& artifact, int e) {
return std::format(
"this host cannot execute '{}': {} (error {}).\n"
" The artifact was built for '{}'. Declare how to run it here:\n"
"\n"
" [target.{}]\n"
" runner = [\"qemu-aarch64-static\"]\n"
"\n"
" The artifact path is appended, or substituted for `{{}}` if the "
"template contains it.\n"
" A host that can execute it directly may pass --no-runner.",
artifact.string(), errno_text(e), e, triple, triple);
}
} // namespaceWindows: ENOEXEC exists in <cerrno> on MSVC; is_windows is in mcpp.platform. Keep the module free of POSIX headers.
BuildContext in prepare.cppm: add after depSourceRoots:
// `<payload>/bin` of every installed `[xlings] deps` payload of the
// runtime-owner manifest, in declaration order. Read by choose_runner's
// lookup (mcpp.build.runner_lookup) so a runner may name a program the
// project declared, without writing the payload's home-and-version path
// into the manifest. Computed by the same resolution fillXpkgDirs uses for
// build programs; a declared-but-absent payload contributes nothing.
std::vector<std::filesystem::path> xlingsDepBinDirs;Fill it next to fillXpkgDirs (:4549), from runtimeOwnerManifest.xlings.deps through xpkg_payload, appending *dir / "bin". Confirm ctx is in scope at that point; if the lambda's enclosing function does not own ctx, compute the vector in the same function that builds the BuildContext and hand it in.
- Step 4: Run to verify they pass
Run: mcpp build && mcpp test unit/test_runner_lookup
Expected: ok.
- Step 5: Commit
git add src/build/runner_lookup.cppm src/build/prepare.cppm tests/unit/test_runner_lookup.cpp
git commit -m "build: runner lookup through declared payloads then PATH, with typed messages (#544)"Files:
- Modify:
src/build/execute.cppm:53-95(cache entry),:150-215(reader),:280-300(writer),:420-451(choose_runner),:1100-1190(try_fast_run),:1191-1320(build_run_target) - Modify:
src/cli/cmd_build.cppm:194-218(cmd_run),src/cli.cppm:383-395(option) - Test: e2e in Task 7 (the unit-testable part is in Task 4)
Interfaces:
-
choose_runner(const BuildContext&, bool noRunner):tmplfilled for any target;freestandingunchanged in meaning; new fieldbool ignoredwhennoRunnerdropped a declared template (so the caller prints one note). -
build_run_target(..., const std::string& target_triple = {}, bool no_runner = false). -
BuildCacheEntry::runnerDeclared(bool, linerunner=0|1, written always; absent on old caches → false). -
Step 1:
choose_runner
struct RunnerChoice {
std::vector<std::string> tmpl;
bool freestanding = false; // an EMPTY tmpl is fatal when true
bool fromManifest = false;
bool ignored = false; // --no-runner dropped a declared template
};
RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner = false) {
RunnerChoice c;
if (auto ft = mcpp::toolchain::triple::parse(ctx.tc.targetTriple))
c.freestanding = ft->is_freestanding();
// Both producers, for every target. The freestanding predicate used to
// gate this read, which is how a runner declared under a hosted cross
// triple was validated, documented and never consulted (#544). It now
// decides one thing only: whether an absent runner is fatal.
c.tmpl = ctx.manifest.buildConfig.runner;
if (auto it = ctx.manifest.targetOverrides.find(ctx.tc.targetTriple);
it != ctx.manifest.targetOverrides.end() && !it->second.runner.empty()) {
c.tmpl = it->second.runner;
c.fromManifest = !ctx.manifest.buildConfig.runner.empty();
}
if (noRunner && !c.tmpl.empty()) { c.tmpl.clear(); c.ignored = true; }
return c;
}--no-runner on a freestanding target with the template dropped: the existing no_runner_message path fires, which is the correct answer (there is nothing to execute directly).
- Step 2:
mcpp runlaunch (replaceexecute.cppm:1264-1319)
const auto choice = choose_runner(*ctx, no_runner);
if (choice.ignored)
mcpp::ui::info("note", std::format(
"--no-runner: ignoring the runner declared for {}", ctx->tc.targetTriple));
if (choice.fromManifest)
mcpp::ui::info("note", std::format(
"[target.{}].runner overrides the runner a dependency supplied",
ctx->tc.targetTriple));
if (choice.freestanding && choice.tmpl.empty()) {
std::println(stderr, "error: {}",
mcpp::freestanding::no_runner_message(ctx->tc.targetTriple));
return 2;
}
if (!choice.tmpl.empty()) {
auto tmpl = choice.tmpl;
const char* pathEnv = std::getenv("PATH");
auto found = mcpp::build::runner_lookup::locate(
tmpl.front(), ctx->xlingsDepBinDirs, pathEnv ? pathEnv : "");
if (!found.program) {
std::println(stderr, "error: {}", mcpp::build::runner_lookup::not_found_message(
ctx->tc.targetTriple, tmpl.front(), found.searched));
return 2;
}
tmpl.front() = found.program->string();
argv = mcpp::freestanding::expand(tmpl, exe);
for (auto& a : passthrough) argv.push_back(a);
mcpp::ui::status("Running", std::format("`{} … {}`", choice.tmpl.front(),
mcpp::ui::shorten_path(exe, pathCtx)));
} else {
argv.push_back(exe.string());
for (auto& a : passthrough) argv.push_back(a);
mcpp::ui::status("Running", std::format("`{}`", mcpp::ui::shorten_path(exe, pathCtx)));
}
std::println(""); std::fflush(stdout);
... childEnv as today ...
int spawnErr = 0;
int rc = mcpp::platform::process::run_exec(argv, childEnv, &spawnErr);
if (spawnErr != 0) {
using namespace mcpp::build::runner_lookup;
if (!choice.tmpl.empty())
std::println(stderr, "error: {}", spawn_failed_message(argv.front(), spawnErr));
else if (classify(spawnErr) == SpawnClass::Unloadable)
std::println(stderr, "error: {}", unrunnable_message(ctx->tc.targetTriple, exe, spawnErr));
else
std::println(stderr, "error: {}", spawn_failed_message(exe.string(), spawnErr));
return 2;
}
return rc == 0 ? 0 : 1;Add import mcpp.build.runner_lookup; to execute.cppm.
- Step 3: Fast path
BuildCacheEntry: add bool runnerDeclared = false; with a comment: "a runner declared for this target makes the fast path a miss, because the fast path has no manifest to read the template from and must not execute the artifact bare when the prepare path would not". Writer: f << "runner=" << (e.runnerDeclared ? 1 : 0) << '\n'; after the subos= line. Reader: optional line runner=. Where the cache entry is populated (search runTargets = assignments in run_build_plan/prepare — git grep -n "\.runTargets" src/build), set e.runnerDeclared = !choose_runner(ctx).tmpl.empty();. In try_fast_run, after if (!chosen) return std::nullopt;: if (match->runnerDeclared) return std::nullopt;. Also make try_fast_run pass &spawnErr to run_exec and print unrunnable_message/spawn_failed_message on failure with exit 2, so the two doors behave the same.
- Step 4: CLI
src/cli.cppm run subcommand: .option(cl::Option("no-runner").help("Execute the artifact directly, ignoring any [target.<triple>].runner (a host that can run it natively)")). Same option on test. cmd_run: bool no_runner = parsed.is_flag_set("no-runner"); and pass it through. Help line at src/cli.cppm:62 for test: append --no-runner.
- Step 5: Build and smoke by hand
mcpp build
cd $(mktemp -d) && $MCPP new probe >/dev/null && cd probe
printf '\n[target.x86_64-linux-gnu]\nrunner = ["/bin/sh", "-c", "echo RUNNER-SAW \"$@\"", "wrap"]\n' >> mcpp.toml
$MCPP run # expect RUNNER-SAW <artifact path>
$MCPP run # second run: fast path must still go through the runner (marker)
$MCPP run --no-runner # expect the note and the program's own output(Use the triple $MCPP toolchain list reports for the host if it is not x86_64-linux-gnu.)
- Step 6: Commit
git add src/build/execute.cppm src/cli/cmd_build.cppm src/cli.cppm
git commit -m "run: honour the declared runner on every target; --no-runner; typed launch failures (#544)"Files:
-
Modify:
src/build/execute.cppm:1324-1345(TestOptionsgainsbool noRunner),:1346-1356(TestRunSummarygainsint notRun,std::string notRunReason),:1527-1560(TestResult::St::NotRun, JSON),:1711-1800(workers),:1855-1905(argv build),:1905-1950(summary) -
Modify:
src/cli/cmd_build.cppm:221-262(to.noRunner = parsed.is_flag_set("no-runner")), and the--workspaceaggregation at:280-395(countnotRuninto the workspace summary line as; N not run) -
Step 1: State and JSON
enum class St { Pass, CompileFail, RunFail, NotRun } status;
...
const char* st = r.status == TestResult::St::Pass ? "pass"
: r.status == TestResult::St::CompileFail ? "compile_fail"
: r.status == TestResult::St::NotRun ? "not_run"
: "run_fail";
// record gains `"reason":"<escaped or empty>"` after `timed_out`.Add std::string reason; to TestResult.
- Step 2: Runner resolution once, before Pass 2
Where argv is built per test (:1855-1875), replace the freestanding gate:
const auto choice = choose_runner(*ctx, testOpts.noRunner);
// resolved once per invocation, outside the loop: hoist `choice` and
// the lookup above the `for (auto& lu : ...)` loop.Above the loop:
const auto choice = choose_runner(*ctx, testOpts.noRunner);
if (choice.ignored && !json) mcpp::ui::info("note", "--no-runner: ignoring the declared runner");
if (choice.freestanding && choice.tmpl.empty()) { ...unchanged error, return 2... }
std::vector<std::string> runnerTmpl = choice.tmpl;
std::string invocationNotRunReason; // non-empty ⇒ nothing is spawned
if (!runnerTmpl.empty()) {
const char* pathEnv = std::getenv("PATH");
auto found = mcpp::build::runner_lookup::locate(
runnerTmpl.front(), ctx->xlingsDepBinDirs, pathEnv ? pathEnv : "");
if (found.program) runnerTmpl.front() = found.program->string();
else invocationNotRunReason = mcpp::build::runner_lookup::not_found_message(
ctx->tc.targetTriple, runnerTmpl.front(), found.searched);
}Per test: argv = runnerTmpl.empty() ? {exe} : expand(runnerTmpl, exe).
- Step 3: Workers
Add std::atomic<bool> hostCannotRun{false}; std::string hostCannotRunReason; std::mutex reasonMutex; next to next. In the worker, before spawning:
if (!invocationNotRunReason.empty() || hostCannotRun.load()) {
std::scoped_lock lock(reportMutex);
if (!json) mcpp::ui::plain(std::format("{} ... not run", r.name));
results.push_back({r.name, TestResult::St::NotRun, 0, {}, {}, 0, false,
invocationNotRunReason.empty() ? hostCannotRunReason
: invocationNotRunReason});
emit_json(results.back());
continue;
}
int spawnErr = 0;
... capture_exec_deadline(..., &spawnErr) / run_exec_deadline(..., &spawnErr) ...
if (spawnErr != 0) {
std::string reason = runnerTmpl.empty()
? (runner_lookup::classify(spawnErr) == SpawnClass::Unloadable
? std::format("this host cannot execute {} artifacts: {}",
ctx->tc.targetTriple, runner_lookup::errno_text(spawnErr))
: runner_lookup::spawn_failed_message(r.argv.front(), spawnErr))
: runner_lookup::spawn_failed_message(r.argv.front(), spawnErr);
{
std::scoped_lock lock(reportMutex);
if (!hostCannotRun.exchange(true)) {
hostCannotRunReason = reason;
if (!json) mcpp::ui::warning(reason); // printed once
}
if (!json) mcpp::ui::plain(std::format("{} ... not run", r.name));
results.push_back({r.name, TestResult::St::NotRun, 0, {}, {}, ms, false, reason});
emit_json(results.back());
}
continue;
}Keep TestResult's field order consistent with these brace-initialisers (add reason last).
- Step 4: Summary
int passed = 0, failed = 0, notRun = 0;
std::string notRunReason;
for (auto& r : results) {
if (r.status == TestResult::St::Pass) ++passed;
else if (r.status == TestResult::St::NotRun) { ++notRun; if (notRunReason.empty()) notRunReason = r.reason; }
else { ++failed; failures.push_back(r.name); }
}
summary.notRun = notRun; summary.notRunReason = notRunReason;
// JSON summary: add "not_run":N,"not_run_reason":"…"
...
const int rc = failed ? 1 : (notRun ? 2 : 0);
if (json) { ...print...; return rc; }
std::println("");
auto counts = std::format("{} passed; {} failed", passed, failed);
if (notRun) counts += std::format("; {} not run ({})", notRun, first_line(notRunReason));
if (rc == 0) { mcpp::ui::status("test result", std::format("ok. {}; finished in {}", counts, timing)); return 0; }
mcpp::ui::error(std::format("test result: {}. {}; finished in {}",
failed ? "FAILED" : "NOT RUN", counts, timing));
if (failed) { println failures block as today }
return rc;first_line = the reason up to its first \n. Workspace fan-out in cmd_build.cppm: sum sum.notRun, print ; N not run in the workspace line when non-zero, and make the workspace rc 2 when any member returned 2 and none returned 1.
-
Step 5: Build, then hand-check with a two-test project and the patched-artifact technique from Task 7
-
Step 6: Commit
git add src/build/execute.cppm src/cli/cmd_build.cppm
git commit -m "test: unrunnable artifacts are reported not-run once, with the reason, and exit 2 (#544)"Files:
-
Create:
tests/e2e/330_runner_hosted_targets.sh -
Step 1: Write the script
#!/usr/bin/env bash
# requires: unix-shell
# 330_runner_hosted_targets.sh — [target.<triple>].runner on a hosted target,
# and what `mcpp run` / `mcpp test` say when the host cannot execute an artifact.
#
# Issue #544. Design: .agents/docs/2026-09-02-runner-beyond-baremetal-design.md §11.
#
# No emulator is needed and none is used: the runner is a shell script that
# records its argv, and the "unloadable artifact" is a real host binary whose
# ELF e_machine is patched to 0xffff after the build, which no binfmt entry and
# no native loader accepts (ENOEXEC on any Linux host, binfmt_misc or not).
# `requires: unix-shell` and nothing else: a `requires: gcc` or `requires: llvm`
# guard skips on both CI shards, and a skip exits 0.
set -uo pipefail
TMP=$(mktemp -d); trap "rm -rf $TMP" EXIT; cd "$TMP"
fail() { echo "FAIL: $*"; exit 1; }
MCPP="${MCPP:?set MCPP to the binary under test}"
HOST=$("$MCPP" toolchain list --format json 2>/dev/null | sed -n 's/.*"host":"\([^"]*\)".*/\1/p' | head -1)
[[ -n "$HOST" ]] || HOST=$(uname -m)-linux-gnu # fallback: adjust if toolchain list has no host field
case "$(uname -s)" in Darwin) echo "SKIP: e_machine patching is ELF-only; the runner half runs below";; esac
"$MCPP" new app >/dev/null || fail "mcpp new"
cd app
mkdir -p tests
cat > src/main.cpp <<'EOF'
#include <cstdio>
int main() { std::puts("ARTIFACT-RAN"); return 0; }
EOF
cat > tests/one.cpp <<'EOF'
int main() { return 0; }
EOF
cat > "$TMP/runner.sh" <<'EOF'
#!/bin/sh
printf '%s\n' "$@" >> "$RUNNER_LOG"
exec "$@"
EOF
chmod +x "$TMP/runner.sh"
export RUNNER_LOG="$TMP/runner.log"
# ── 1. a declared runner is used on a hosted target, on both doors ─────────
printf '\n[target.%s]\nrunner = ["%s"]\n' "$HOST" "$TMP/runner.sh" >> mcpp.toml
out=$("$MCPP" run 2>&1) || fail "run through runner: $out"
grep -q "ARTIFACT-RAN" <<<"$out" || fail "artifact output missing: $out"
[[ -s "$RUNNER_LOG" ]] || fail "runner was not invoked"
exe=$(head -1 "$RUNNER_LOG")
[[ "$exe" == */bin/app ]] || fail "runner argv[0] is not the artifact: $exe"
: > "$RUNNER_LOG"
out=$("$MCPP" run 2>&1) || fail "second run (fast path): $out"
[[ -s "$RUNNER_LOG" ]] || fail "fast path bypassed the runner"
# ── 2. --no-runner executes directly and says so ───────────────────────────
: > "$RUNNER_LOG"
out=$("$MCPP" run --no-runner 2>&1) || fail "--no-runner: $out"
grep -q "ARTIFACT-RAN" <<<"$out" || fail "--no-runner lost the program output: $out"
grep -q "no-runner" <<<"$out" || fail "--no-runner printed no note: $out"
[[ ! -s "$RUNNER_LOG" ]] || fail "--no-runner still invoked the runner"
# ── 3. runner not found: error names the program, the search list, exit 2 ──
sed -i "s|runner = \[.*\]|runner = [\"mcpp-e2e-no-such-runner\"]|" mcpp.toml
out=$("$MCPP" run 2>&1); rc=$?
[[ $rc -eq 2 ]] || fail "missing runner: exit $rc, want 2: $out"
grep -q "runner 'mcpp-e2e-no-such-runner'" <<<"$out" || fail "missing runner not named: $out"
grep -q "Searched:" <<<"$out" || fail "search list missing: $out"
grep -q "ARTIFACT-RAN" <<<"$out" && fail "artifact ran without its runner"
out=$("$MCPP" test 2>&1); rc=$?
[[ $rc -eq 2 ]] || fail "test with missing runner: exit $rc, want 2: $out"
grep -qE "NOT RUN\. 0 passed; 0 failed; 1 not run \(runner 'mcpp-e2e-no-such-runner'" <<<"$out" \
|| fail "test summary: $out"
# ── 4. no runner, unloadable artifact (ELF only) ───────────────────────────
if [[ "$(uname -s)" == Linux ]]; then
sed -i "/^\[target\.$HOST\]/,+1d" mcpp.toml
"$MCPP" build >/dev/null 2>&1 || fail "rebuild without runner"
bin=$(ls target/*/*/bin/app | head -1)
printf '\xff\xff' | dd of="$bin" bs=1 seek=18 conv=notrunc status=none
out=$("$MCPP" run 2>&1); rc=$?
[[ $rc -eq 2 ]] || fail "unrunnable: exit $rc, want 2: $out"
grep -q "Exec format error" <<<"$out" || fail "kernel answer missing: $out"
grep -q "\[target.$HOST\]" <<<"$out" || fail "paste-able key missing: $out"
grep -q "runner = \[" <<<"$out" || fail "runner example missing: $out"
# the artifact must still be the patched one, or the criterion measured a rebuild
od -An -tx1 -j18 -N2 "$bin" | grep -q "ff ff" || fail "artifact was rebuilt under the run"
# mcpp test: one test and two tests, streaming and capturing paths
cat > tests/two.cpp <<'EOF'
int main() { return 0; }
EOF
for n in 1 2; do
[[ $n -eq 1 ]] && rm -f tests/two.cpp
"$MCPP" build >/dev/null 2>&1
"$MCPP" test --list >/dev/null 2>&1
# build the test binaries once so they exist to patch: run the suite (it runs them), then patch and rerun
"$MCPP" test >/dev/null 2>&1 || true
for t in target/*/*/tests/bin/*; do [[ -f "$t" ]] && printf '\xff\xff' | dd of="$t" bs=1 seek=18 conv=notrunc status=none; done
out=$("$MCPP" test 2>&1); rc=$?
[[ $rc -eq 2 ]] || fail "test unrunnable ($n): exit $rc, want 2: $out"
grep -qE "NOT RUN\. 0 passed; 0 failed; $n not run \(this host cannot execute" <<<"$out" \
|| fail "test summary ($n): $out"
[[ $(grep -c "cannot execute" <<<"$out") -eq 2 ]] || fail "reason printed other than once + summary ($n): $out"
jout=$("$MCPP" test --message-format json 2>/dev/null)
[[ $(grep -c '"status":"not_run"' <<<"$jout") -eq $n ]] || fail "json not_run count ($n): $jout"
grep -q "\"not_run\":$n" <<<"$jout" || fail "json summary not_run ($n): $jout"
done
fi
# ── 5. array-valued typo is reported and runner is in the list ─────────────
printf '\n[target.%s]\nrunnerX = ["x"]\n' "$HOST" >> mcpp.toml
out=$("$MCPP" build 2>&1)
grep -q "unsupported key 'runnerX'" <<<"$out" || fail "array typo not reported: $out"
grep -q "Supported keys: cxx_runtime, linkage, runner, sysroot, toolchain" <<<"$out" || fail "runner not listed: $out"
echo "PASS: 330_runner_hosted_targets"Adjust the test-binary location (target/*/*/tests/bin/*) to where mcpp test actually places them (check ls target/*/*/ after a run) and the host-triple discovery to what mcpp toolchain list prints; both are facts to read from the built engine, not to guess.
- Step 2: Run against the unfixed engine to record the failure, then against the fixed one
MCPP=$(which mcpp) bash tests/e2e/330_runner_hosted_targets.sh # released 2026.8.17.1: must FAIL at section 1
MCPP=$PWD/target/*/*/bin/mcpp bash tests/e2e/330_runner_hosted_targets.sh # must PASSAlso re-run the neighbours: 130, 131, 132, 178.
- Step 3: Commit
git add tests/e2e/330_runner_hosted_targets.sh
git commit -m "e2e: runner on hosted targets, not-run reporting, --no-runner (#544)"Files:
-
Modify:
docs/05-mcpp-toml.md(§2.7.1 table row; new §2.7.3 "Running an artifact this host cannot execute" placed after §2.7.2 with the two forms and the[xlings] depsconsequence; §2.13 per-platform values),docs/13-baremetal.md(cross-reference under "An absent runner"),docs/15-openkal-cross.md(hosted example after:190),docs/17-the-project-environment.md(per-platform values),docs/11-machine-output.md(new §7 kindmcpp.testdescribing the NDJSON record and summary, withnot_run) -
Modify: the five
docs/zh/twins with the same content in Chinese. -
Step 1: Write the English sections (register per
.agents/skills/mcpp-docs-style/SKILL.md; declarative headings; state the[xlings] depsconsequence in one paragraph; state that[xlings]per-platform values are resolved against the host at load, that the keys arelinux,macos,windows,default, that an entry without a match is not declared on that host, and that an unknown key is an error). -
Step 2: Write the Chinese twins.
-
Step 3: Run the style check and the doc parity check
bash .github/tools/check_docs_style.sh- Step 4: Commit
git add docs
git commit -m "docs: runner on hosted targets, not-run reporting, [xlings] per-platform values (#544)"- Step 1: Bump
mcpp.tomlversion andmodules/versioning/src/version.cppmto2026.9.2.1(next free.Nfor today; checkgit tag -l 'v2026.9.2.*'). Runbash .github/tools/check_version_pins.sh. - Step 2: Full local gates:
mcpp build && mcpp test(unit),MCPP=... bash tests/e2e/330_runner_hosted_targets.sh,bash .github/tools/check_modules_wiring.sh,bash .github/tools/check_docs_style.sh,git diff origin/main --diff-filter=D --name-onlymust be empty,git log --oneline origin/main..HEADshows only this branch's commits. - Step 3: Push and open the PR with a body in English academic register: the defect, the three decisions (no fallback, exit 2,
--no-runner), the[xlings]per-platform addition, the test criteria, and the behaviour changes for existing manifests. Link #544. - Step 4: Watch CI (
gh pr checks <n> --watch); fix and push until every required check is green.
- Step 1: Self-review the PR diff against design §0 and §11 (each D1–D6 has a visible implementation or a stated deferral; every criterion row in §11 has an assertion in e2e 330; no criterion is a substring search of a line that could be silent). Use
/code-reviewon the PR. - Step 2: Merge with
gh pr merge <n> --squash --admin; confirmgh pr view <n> --json state,mergeCommit; confirm the run onorigin/main's HEAD SHA is green. - Step 3: Release:
git tag v2026.9.2.1 <merge-sha> && git push origin v2026.9.2.1; watchrelease.yml; ifpublish-ecosystem's GitCode leg times out, download the missing assets from the verified GitHub release and upload with/usr/bin/python3 ~/.local/bin/gtc release upload xlings-res/mcpp <file> --tag 2026.9.2.1, then GET-verify each asset fromhttps://gitcode.com/xlings-res/mcpp/releases/download/2026.9.2.1/<asset>withcmp. Merge the index bump PR onopenxlings/xim-pkgindex(switchgh authto the account with push, switch back after), confirmgit show origin/main:pkgs/m/mcpp.luahaslatestat2026.9.2.1, then bump the bootstrap pin in.xlings.jsonthrough a PR. - Step 4: Sandbox verification:
xlings update;xlings subos create e544-<date>; insidexlings subos use e544-<date> --sandbox --cmd "...": configuremcpp self config --mirror CN, installmcpp@2026.9.2.1, then run a base64-inlined script that (a) builds an openkal cross project foraarch64-linux-muslwith[xlings] deps = [{ linux = "qemu-user-aarch64" }]andrunner = ["qemu-aarch64-static"], runs it, and asserts the program output; (b) runsmcpp test --target aarch64-linux-musland asserts the summary counts; (c) without the runner asserts theExec format errormessage and exit 2. Assert the installed version withmcpp --versionat the start of the script, with a lower bound not an exact match. - Step 5: Record the sandbox transcript summary in
.agents/docs/2026-09-02-runner-beyond-baremetal-design.md§13 "Verification" with the exact commands and the lines observed.