Skip to content

feat(device): 具名 runner、工具档位、--locked 断言与 emit sbom (2026.9.4.2) - #551

Merged
Sunrisepeak merged 10 commits into
mainfrom
feat/device-slots-repro-deliverable
Sep 4, 2026
Merged

feat(device): 具名 runner、工具档位、--locked 断言与 emit sbom (2026.9.4.2)#551
Sunrisepeak merged 10 commits into
mainfrom
feat/device-slots-repro-deliverable

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Sep 4, 2026

Copy link
Copy Markdown
Member

方案:.agents/docs/2026-09-04-named-runners-and-the-universal-command-surface.md(v5)

三条约束,按顺序施加,产生了这个形状:

① 一级命令必须所有场景都用得到 ② 默认覆盖 80%,其余用 option ③ 核心只放通用框架

前一版三条都不满足

mcpp flash/monitor/debug 在任何非固件工程里都是死命令,且把嵌入式词汇
写进了引擎 —— web 包想加 serve 得等引擎发版。实测:flash/monitor/debugger
在引擎里散落 56 处、8 个文件,加第五个动作要动 9 处 —— 而这个代价是前一版
commit message 自己引用的,然后付了四遍。

⭐⭐ 第二条约束抓到更深的错:烧录就是运行

真板上「运行一个程序」= 写进去 + 复位 + 接输出 + 读回退出码 —— probe-rs run
一条命令,qemu-system-* -kernel 也是。同一个动作在两个环境的两种实现,
不是两个动作。
前一版却要求 mcpp run --runner flash,把开发者最常做的事变成了
需要额外参数的事。

⇒ 板级包的 hardware feature 移动的是默认 runner:

mcpp run     # 模拟器             ← 命令
mcpp run     # 真板,同一条命令     ← 不变

具名 runner 服务剩下的 20%:只写不跑、看串口、调试服务端、擦片。

引擎不认识任何名字

名字是数据,装在一条指令的值里。flashservesubmitlogcat 对引擎
一样陌生,代价都是零。

是否终止由包声明,不由名字推:openocd -c "program … exit" 会终止而
openocd -c "init" 不会,拼写到最后一个参数为止都一样 —— 没有任何 argv 能表达它,
而引擎也没有名字表可供推断。

写程序名,不要写路径。 runner_lookup(#544)先搜本包 [xlings] deps 声明
的载荷 bin/,再搜 PATH。实测:mcpp::runner("qemu-system-arm") 一行,直接
从声明的载荷里解析并启动成功 —— 而两个现有板级包仍在用 xpkg_dir + std::format
拼绝对路径、外加一段 mcpp::warning 兜底(11 行)。⚠️ 那段兜底所补救的失败模式
(声明≠安装 ⇒ xpkg_dir 空 ⇒ 静默无 runner)在裸名写法下根本不存在。

改名

runner-exclusiverun-exclusive:性质是「这个目标的运行不能重叠」,对一块
板、一张 GPU、一个串口、一个单席位 license 同样成立。没有一处是关于「设备」的。

mcpp sbommcpp emit sbom

emit 早就是「生成描述本工程的文档」,连 -o 形状都一样。加命令代替扩抽象,
一天之内犯了两次
—— 这是第二次的修正。

判据

tests/e2e/333(十条):默认 runner、具名 runner、未声明的名字被拒并列出本工程
实际有哪些
--locked 两个方向、sbom 是合法 CycloneDX 且读锁而非重解析
同一个包在两个 feature 下 mcpp run 命令不变而默认 runner 移动、所选环境
没有的 runner 被拒而非伪造。

⭐ 对指令表量化的单测抓到一个真缺陷:删掉一行后 std::array<Def, 20> 的大小没改,
留下一个 wire 名为空的默认构造项。

回归

单测 97/97;e2e 130139 + 332 + 重写的 333 共 12 个全绿。

… sbom

Four axes a project is asked about before it is adopted, and none of them had
an answer: how the artefact reaches a device, whether the build is
reproducible, what went into it, and whether an emulator and real silicon are
one package or two.

## The device slots

`run`, `flash`, `monitor` and `debug` are one shape — an argv the BOARD knows
and a TOOL performs, addressed absolutely, with the artefact appended or
substituted for `{}`. `runner` has carried that shape since 2026.8.19; three
special-case commands would have carried it three more times. So the slot is
the parameter: one directive-table row each, one reader, one CLI shape.

⚠️ What no argv can express is which of them ENDS. `run` and `flash` finish and
hand back a verdict; `monitor` and `debug` have no natural end, so a live
process is success for them and a hang for the other two. `Semantics` answers
that from the slot, because `openocd -c "program … exit"` and `openocd -c
"init"` are spelled alike up to the argument the board chose.

`debug` starts a SERVER and stops there. The client is the user's debugger or
their IDE, which reaches mcpp through docs/11; driving it would put mcpp in the
middle of a session it has nothing to add to.

⚠️ `runner-exclusive` is the first thing a physical board needed that an
emulator never did. `mcpp test` runs binaries on a worker pool; one probe on
one device is a mutex, and two workers reaching for it do not fail — they
interleave, and the verdict is about neither test. The board knows this about
itself, so it says so once and no project remembers `-j1`.

## Emulator or hardware is a feature, not a fork

A board reached through QEMU and the same board reached through a probe differ
in the argv of their device slots and nothing else. Publishing two packages
would duplicate a linker script, startup code and a module surface to vary four
strings. `mcpp::has_feature()` already existed, so this needed no engine work
at all — it is what the layering was for. e2e 333 builds one board package and
drives it both ways.

## --locked

The lock has always been written after resolution and never read back; its own
header said so. This makes it an ASSERTION rather than a pin: the resolution
that happens must equal the one recorded, and a difference names the package
that moved and both versions. That is the half reproducibility needs first, and
it is what Cargo's flag of the same name means.

⚠️⚠️ And it must not meet the fast path. Measured before that guard existed: a
deliberately corrupted lock passed `mcpp build --locked` and printed "Finished"
— the flag accepted, the build correct, the assertion never run. A criterion
that is skipped is worse than one that is absent, because the green reads as a
verification.

## mcpp sbom

CycloneDX 1.5 over the recorded resolution. Everything a bill of materials
names is already in mcpp.lock, so this is an output format rather than a
mechanism: it resolves nothing and asks the network for nothing.

⚠️ It reads the lock rather than re-resolving, which is the one property such a
document must have — an SBOM describing a different graph from the one that was
built is worse than none. Asserted in e2e 333 by editing the lock and checking
the output follows it. An unknown licence is emitted as NOASSERTION rather than
omitted: an absent key reads as "not examined", and a reviewer cannot filter on
silence.

## Two propagation sites, and the one that was missed first

Dependency-supplied RunGlobal entries reach the root through a different path
from a package's own directives. Wiring only `apply()` left `mcpp flash`
reporting "no flash is configured" while `mcpp run` found the runner the same
build program emitted three lines away — measured. Both sites now iterate the
slot table instead of naming `runner`.

Protocol version 6. 97/97 unit tests; freestanding e2e 130-139 and 332 green.
docs/18 specifies the four device actions, why termination is a property of the
slot rather than of the argv, and why the emulator/hardware choice is a feature
of one board package rather than two packages. docs/19 states what a project's
own review process asks and cannot currently cite: which releases are
supported, which surfaces are stable, and which — build fingerprints, cache
layout, target/ — are explicitly not interfaces.

Both mirrored in Chinese, per this repository's convention.

The plan document records the second round: two findings the plan did not
predict (a second propagation path for dependency-supplied RunGlobal entries,
and two fast paths that would have made the new slots and --locked silently
vacuous), and the six-axis reading that follows.
Replaces the four hardcoded device slots of the previous commit. Three
constraints, applied in order, produced this shape:

  1. A top-level command must be usable in every domain.
  2. The default must cover the common case; options carry the rest.
  3. The core carries the general framework; the rest is configuration.

The previous design failed all three. `mcpp flash` / `monitor` / `debug` were
dead commands in any project that is not firmware, and they put embedded
vocabulary into the engine: a web package could not add `serve`, nor a cluster
package `submit`, without an engine release. Measured before this change:
`flash`, `monitor` and `debugger` appeared 56 times across 8 engine files, and a
fifth action would have touched nine places — a cost this commit's predecessor
quoted in its own message and then paid four times.

⭐⭐ AND THE SECOND CONSTRAINT CAUGHT THE DEEPER ERROR. On real hardware,
running a program IS writing it, resetting, attaching and reading the exit
status — `probe-rs run` is one command, exactly as `qemu-system-* -kernel` is.
The two are one action in two environments, not two actions. The previous design
required `mcpp run --runner flash` there, making the most common thing a
developer does the one needing an extra argument.

So a board's `hardware` feature moves the DEFAULT runner, and the command does
not change between an emulator and a board. Named runners serve what remains:
writing without running, observing a console, a debug server, erasing.

The engine now knows only that named runners exist. The name is data, carried in
the value of one directive, and `flash`, `serve`, `submit` and `logcat` cost it
the same: nothing.

⭐ Termination is declared rather than inferred, because no argv can express it
and the engine has no list of names to infer from:
`openocd -c "program … exit"` terminates, `openocd -c "init"` does not, and the
two are spelled alike up to the argument the package chose.

`runner-exclusive` becomes `run-exclusive`: the property is that this target's
runs cannot overlap, which holds for one board on one probe, one GPU, one serial
port and a single-seat licence alike. Nothing about it is a device.

`mcpp sbom` becomes `mcpp emit sbom`. `emit` already meant "generate a document
describing this project" and already carried `-o`; a separate top-level command
was a second spelling of an abstraction that existed. That was the same mistake
as the first one, made twice in a day: adding a command instead of extending an
abstraction.

⭐ The unit test that quantifies over the directive table caught a real defect
here: the table's declared size still said 20 after a row was removed, leaving a
default-constructed entry with an empty wire name.

97/97 unit tests; e2e 130-139, 332 and the rewritten 333 green.
@Sunrisepeak Sunrisepeak changed the title feat(device): runner 的三个兄弟槽、--locked 断言与 mcpp sbom (2026.9.4.2) refactor(runner): runner 有了名字,引擎一个都不认识 (2026.9.4.2) Sep 4, 2026
⚠️⚠️ THE CASE THE FEATURE EXISTS FOR WAS THE ONE THAT DID NOT WORK.

`runner_lookup` (#544) lets a runner name its program without writing a
payload's home-and-version path into a manifest. But the directories it searched
were collected from `runtimeOwnerManifest.xlings.deps` — the ROOT project's
declarations alone. So the bare name resolved when the CONSUMER declared the
tool, and failed when the board-support package did.

That is backwards. A board package is precisely the thing that knows which
emulator or probe reaches its machine; requiring the consumer to declare it as
well is the duplication the board package exists to remove.

Measured on `mcpplibs/aarch64-virt-rt` with its runner reduced to the bare name
`qemu-system-aarch64`: `mcpp run --target aarch64-none-elf` searched PATH, did
not find it, and reported a missing runner — while the emulator sat installed in
the payload the board had declared two lines above. With this change the same
example boots and prints.

The collection now spans every package in the graph, root first: a consumer that
declares its own payload still decides, and a dependency answers when the
consumer said nothing. A payload declared but not installed contributes nothing
and the lookup continues to PATH, unchanged.

⭐ This is what makes the board-package simplification real. Naming the program
replaces `mcpp::xpkg_dir` + `std::format` + a conditional + a `mcpp::warning`
fallback — eleven lines — and it DELETES a failure mode rather than moving it:
`xpkg_dir` answers empty for anyone building from a checkout, so the old shape
configured no runner and needed an advisory to explain why. There is nothing to
explain when the lookup itself reports which directories it searched.

`tests/e2e/334` covers it, and asserts the half that matters more than
resolution: a declared runner whose program is missing is an ERROR, never a
fallback to executing the artifact on the build host.

97/97 unit; e2e 130-139, 332-334 green.
Two findings the design did not predict, both of which only appear when the
thing is built:

⭐⭐⭐ `xlingsDepBinDirs` was collected from the ROOT manifest alone, so a board
package naming its emulator by bare name did not resolve — the exact case the
simplification exists for, and backwards from what it should be.

⭐⭐ openarch has a primitive for "switch to another saved context" and none for
"switch the context this trap will return to". Every architecture needs the
second in order to preempt. `examples/switch` could not have found it: it never
enters a trap.
…run opposite

§15 records what was settled, including that batch 1's engine interfaces become
a compatibility contract on release, so their shape is frozen before it merges.

§16 re-orders the batches with picolibc parallel rather than queued, and writes
down a pair I had not stated together:

  * the board packages must wait for the ENGINE to publish, because the bare-name
    lookup they now rely on ships with it — the mirror of "consumers publish
    first";
  * picolibc must publish before the cortex-m-rt version that names it, because
    a version reference cannot resolve to an unpublished package — the ordinary
    case of the same rule.

⭐ Which is what the libc feature buys: cortex-m-rt 0.1.0 ships on the zero-libc
tier waiting for nobody, and gains the feature at 0.2.0. The near tier is not
held behind the far one.
…stalled

## Two halves of one defect

`[xlings.workspace]` had one list. A board-support package naming both an
emulator (to run) and a debug probe (to reach hardware) installed both for every
consumer, including one that only wanted the library to compile — while package
dependencies have had `[dependencies]` / `[build-dependencies]` /
`[dev-dependencies]` since the beginning.

    [xlings.workspace]
    "xim:qemu-arm" = "9.2.4-1"                            # unchanged behaviour
    "xim:probe-rs" = { version = "0.24.0", when = "run" }

    [feature-xlings.hardware]
    "xim:probe-rs" = "0.24.0"                             # never fetched otherwise

| when | installed by | reaches a consumer |
|---|---|---|
| (omitted) | every verb that builds | yes |
| build | every verb that builds | yes |
| run | `mcpp run`, `mcpp test` | yes |
| dev | only the package that declared it, as the root | no |

Omitting `when` is the pre-2026.9.4.2 behaviour exactly, so no manifest changes.
The tier rides the entry rather than a second table, the shape `[dependencies]`
already uses; `[feature-xlings.<f>]` is `[feature-deps.<f>]`'s spelling.

## ⚠️⚠️ And provisioning now spans the graph, because looking did already

The previous release extended the runner's bare-name LOOKUP to every package in
the graph and left provisioning at the root. That is a search of directories
nothing installed into — a lookup that can only fail. Both are one expression
now, so they cannot drift.

Provisioning happens in two passes for an ordering reason: the root's list is
needed before the graph exists (a toolchain may depend on it), and a
dependency's is not known until resolution. The second pass provisions only what
the first did not, under its own stamp, so an unchanged project still pays for
no xlings round trip.

## ⚠️ The hazard a tier introduces, and the gate for it

`mcpp build` installs less than `mcpp run` needs, and the run fast path exists
precisely to skip the pass that would install the difference. The build cache
records whether a build left a run-tier tool unprovisioned; `try_fast_run`
declines such an entry, exactly as it declines one that records a runner.

## The criterion tests what mcpp REQUESTS, not what got installed

Verifying a tier by installing needs a clean machine and a network. It is also
the wrong object: what the tier changes is the SET, and `MCPP_NO_AUTO_INSTALL=1`
refuses to provision while naming exactly that set. `tests/e2e/335` reads both
commands on one project, so the criterion carries its own denominator — an mcpp
that provisioned nothing, or a manifest that never parsed, fails the `run` half.

## Discoverability

`mcpp why runners` lists what this project supplies, beside everything else it
reports resolving; `mcpp run --list-runners` is the same read alone. No stamped
one-time hint: a cache hit does not re-run, so it would be silent on the build
that needed it most.

97/97 unit (5 new); e2e 88, 130-131, 327, 332-335 green.
@Sunrisepeak Sunrisepeak changed the title refactor(runner): runner 有了名字,引擎一个都不认识 (2026.9.4.2) feat(device): 具名 runner、工具档位、--locked 断言与 emit sbom (2026.9.4.2) Sep 4, 2026
…unit

Every other 32-bit row in the table is M-profile: an MPU that describes regions
by base and limit, and no page-table entry at all. A-profile has a real MMU with
a walker, so `armv7a-none-eabi` / `-eabihf` are the first targets on which an
address-space abstraction can be asked what a 32-BIT machine's entry looks like
— short descriptors are 32 bits wide, long (LPAE) ones 64. openarch's layer has
never been able to put that question to a 32-bit machine.

Both rows are `verified`, and the column records what was RUN. Measured
2026-09-04 under `xim:qemu-arm@9.2.4-1`: each built an image that booted on
`-M virt -cpu cortex-a15`, printed over semihosting and reported its exit
status.

## ⚠️ The soft row needs `-mfpu=none`, measured on THIS architecture

`armv7-a` is not `thumbv7em`, so the M-profile measurement says nothing about
it. Measured on llvm 22.1.8, the same float multiply: 1 VFP instruction under
the soft-float ABI, 0 with `-mfpu=none`.

## ⚠️ And the semihosting exit call is spelled differently

`SYS_EXIT` (0x18) on AArch32 takes the reason code in `r1` DIRECTLY; the
`{reason, code}` block every Cortex-M board here passes is `SYS_EXIT_EXTENDED`
(0x20), which exists because a 32-bit `r1` cannot carry both. Measured: passing
the block to 0x18 prints correctly and reports the WRONG exit status — a
program exiting 0 reported 1. `tests/e2e/336` asserts the status and not only
the output, and takes it from qemu rather than from the tail of a pipeline.

## ⭐ The unit test's predicate was a spelling, not a property

`SoftFloatMProfileRowsDisableTheFpu` filtered on `starts_with("thumb")`. The
rule it states applies to every 32-bit ARM row with a float-ABI suffix, so
adding these two left the rule in force and the test SILENTLY not covering
them: the loop skipped the new rows and every assertion still passed. The
predicate now asks the property, and the test counts A-profile rows so the
denominator cannot go back to zero unnoticed.

97/97 unit; e2e 332, 336 green.
…emanded

336 declares `# requires: qemu-arm`, which no sharded runner has, so on a shard
it exits 0 without running a single row. Adding it to the loop and not to the
assertions below would have been that defect one layer in — the same shape the
comment above 332 already records. Both the PASS line and a row count are
demanded, because a fixture that stopped iterating still prints its PASS line.
⚠️⚠️ THE SUPPORT MATRIX IS A DECLARATION, AND ADDING A TARGET ROW WITHOUT
ADDING IT HERE IS A RED SCAN RATHER THAN A SILENT GAP — which is what the table
is for. `scan (linux-aarch64)` reported the two new targets as measured cells
with no expectation.

Twelve cells: four hosts × the compilers each declares. The shape follows the
M-profile rows exactly, because the answer is the same one — llvm serves them
from a payload that needs no per-host cross build, and every gcc/msvc cell is a
`capability-pin` refusal.

Measured locally on linux-x86_64: 42/42 payload and 16/16 graph cells match.
@Sunrisepeak
Sunrisepeak merged commit ece01a2 into main Sep 4, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants