[v2] autoexec validation always fails with score 0.2 on cgroup-v2 hosts — sandbox cgroup is derived from the daemon's own (non-distributable) cgroup
Summary
On a standard cgroup-v2 + systemd Linux host, evolver autoexec can never pass validation: every executed cycle ends as status: failed / resolutionStatus: regressed with score: 0.1–0.2, even when the produced diff is correct (proofOfWork.git_diff.files > 0).
The cause is in the validation sandbox, not in the runner or the model.
Environment
- evolver
2.0.37(npm global,evolver autoexec --solo,runner: "llm") - Ubuntu 24.04 under WSL2, kernel
6.6.87.2-microsoft-standard-WSL2 - systemd
255 (255.4-1ubuntu8.17), cgroup v2 unified (/sys/fs/cgroup/cgroup.controllerspresent) - Node
v24.20.0 - Daemon runs as a systemd user service with
Delegate=yes(recommended practice)
Symptom
Every smoke/real cycle:
{
"taskId": "smoke-valcmd-v6-20260917",
"status": "failed",
"finalStage": "failed",
"reason": "失败 score=0.2 → regressed",
"outcome": { "status": "failed", "score": 0.2 },
"proofOfWork": { "kind": "git_diff", "git_diff": { "files": 1, "lines": 1 } }
}The diff was applied correctly — validation simply never ran.
Root cause chain
@evomap/evolver-cli/dist/requiredSandboxValidation.js:23—requireIsolation: trueis hardcoded:const result = await runner(safeCommands, cwd, { ...options, requireIsolation: true });(Same hardcoded value also in
evolver-cli/dist/recipe.js:505,evolver-cli/dist/modelCompatibility.js:232,evolver-proxy/dist/bin/evolver-proxy.js:721. We grepped the whole installed tree — there is no env var or config key that turns this off.)@evomap/evolver-core/dist/verify/sandboxedValidation.js:130-133— when isolation is unavailable, validation is skipped and a hardcoded0.2is returned:const isolationCheck = opts.unshareCheck ?? (opts.requireIsolation ? readOnlyIsolationAvailable : unshareNetAvailable); const isolated = isolationCheck(); if (opts.requireIsolation && !isolated) { return { passed: false, ..., score: 0.2, results: [], skipped: [], isolated: false }; }readOnlyIsolationAvailable()issandboxResourceLimitsAvailable() && readOnlyFilesystemIsolationAvailable().sandboxResourceLimitsAvailable()→createSandboxResourceGroup()(evolver-core/dist/verify/sandboxRunner.js) creates the sandbox cgroup under the process's own cgroup:const parent = currentCgroupPath(); // = readFileSync('/proc/self/cgroup') path = join(parent, `evolver-validation-${process.pid}-${Date.now()}-...`); mkdirSync(path, { mode: 0o700 }); if (!configureSandboxResourceGroup(path)) throw new Error('required cgroup v2 controllers are not delegated');configureSandboxResourceGroup()requires all seven files to exist:cgroup.procs,cgroup.kill,memory.max,memory.swap.max,memory.oom.group,pids.max,cpu.max. One missing →null→ isolation unavailable → hardcoded0.2.So the sandbox requires
cpu/memory/pidsto be listed incgroup.subtree_controlof the daemon's own cgroup.
Why that is structurally impossible
The kernel enforces the "No Internal Process Constraint": a non-root cgroup can distribute domain controllers to children only when it has no processes of its own. The daemon process necessarily lives in the cgroup that currentCgroupPath() returns — so that cgroup can never carry those controllers.
Measured on the host above:
# daemon's own cgroup
cgroup.type : domain
procs : 1
subtree_control : '' <-- empty, so the sandbox dir gets no memory.max etc.
# enable controllers while the cgroup is empty (possible)
$ echo "+cpu +memory +pids" > .../cgroup.subtree_control
$ cat .../cgroup.subtree_control
cpu memory pids
$ ls .../probe-child # every required file appears
memory.max memory.swap.max memory.oom.group pids.max cpu.max
# ... but now nothing can live there:
$ echo <pid> > .../cgroup.procs
write error: Device or resource busy <-- both for the original process and for a fresh oneSame result through systemd when the main process is started after delegation is prepared:
systemd: Failed to attach to cgroup /user.slice/.../evolver-cg-probe.service: Device or resource busy
systemd: Main process exited, code=exited, status=219/CGROUPHost-wide scan — the root cgroup is the only one that has both (it is exempt from the constraint):
procs=221 'cpu memory pids' (root)
procs=0 'cpu memory pids' /user.slice/user-1000.slice/[email protected]
procs=0 'cpu memory pids' /user.slice/.../app.slice
procs=0 'memory pids' /system.sliceDelegation was verified to work up to the unit level (Delegate=yes, DelegateControllers=cpu cpuset io memory pids, and the chain user.slice → user-1000.slice → [email protected] → app.slice all distribute cpu memory pids). We also tried DelegateSubgroup=payload (systemd 254+), which leaves the unit cgroup process-free — the controllers enable fine there, but then currentCgroupPath() points at the subgroup, which holds the daemon again: same dead end.
Impact
On any modern Linux with systemd + cgroup v2, evolver autoexec executes nothing beyond "prompt → diff", because no host setup can satisfy step 4. The only configuration that could work is a process running directly in the real root cgroup (exempt), which normally means privileged/root execution outside a delegating manager.
Suggested fixes (any one would unblock this)
- Configurable sandbox parent: allow the sandbox cgroup root to be specified (env/config), and/or allocate it as a sibling under the parent of
currentCgroupPath()— a cgroup that the host manager can keep process-free and delegated. - Degrade gracefully: don't hard-fail with the sentinel
0.2when only some controllers/limits are available. Better: run validation anyway (with the limits that exist, or withunshare-only isolation) and reportisolated: falsein the result instead of scoring it as a regression. A documented escape hatch (e.g.EVOLVER_REQUIRE_ISOLATION=0) would also help — currently none exists. - Document the requirement: if sandboxed validation genuinely requires the root cgroup, that should be stated explicitly, because no standard systemd-managed deployment can satisfy it.
Happy to re-run any probe or provide full logs — just say which artifact you want.
Source: EvoMap/evolver