carlos / platform Public

stopevidence: pid 0 in cgroup.procs is an unidentifiable member, a stray (CI fix)

The kernel lists pid 0 in cgroup.procs for a member outside the reader's pid
namespace. readProcs now accepts it (still refusing non-numeric, signed or
negative entries); StepStray fails on it with a count of unidentifiable
members, Stray carries a 0 marker, and it is never taken for the agent.
TestReconstructReadsTheRealOwnLeaf now asserts the read succeeds and the
verdict agrees with Stray, so it holds on a shared runner cgroup. New copy
row MV-N181 (pending).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9e99d7c105095334c04450b1069fe6daa243a35e parent f417182
5 files changed, +82 −7
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +1 −0
  • internal/moves/evidence.go +3 −1
  • internal/stopevidence/reconstruct_linux.go +22 −4
  • internal/stopevidence/reconstruct_linux_test.go +55 −1

CI passed — run details

diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index aa34c8d7b..04db0fd77 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -69,7 +69,7 @@ var movesCopyIDs = []string{
"MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162",
"MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168",
"MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174",
- "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180",
+ "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181",
}
// copyRow is one row of the record. The File cell is the file the sentence is
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 7292d1f8b..57c2347bf 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -227,6 +227,7 @@ Columns: id, file, text, status.
| MV-N178 | internal/console/apiinstancemove.go | the instance or its boxes changed under this request. read its state and try again | pending |
| MV-N179 | internal/console/apiinstancemove.go | the move could not be recorded and is held until an operator checks the record | pending |
| MV-N180 | internal/provision/provision.go | the files directory already holds files this move did not write | pending |
+| MV-N181 | internal/stopevidence/reconstruct_linux.go | <count> process(es) in the agent's cgroup cannot be identified | pending |
## Plan 3
diff --git a/internal/moves/evidence.go b/internal/moves/evidence.go
index 95e39bab8..c98ca7900 100644
--- a/internal/moves/evidence.go
+++ b/internal/moves/evidence.go
@@ -32,7 +32,9 @@ var (
// observation is from durable, observable state, so a restarted agent
// writes the same report. Steps holds one Check per step id; a step that
// never ran is absent, and an absent step is not OK. Stray lists the pids
-// StepStray found in the agent's leaf besides the agent, for the alert.
+// StepStray found in the agent's leaf besides the agent, for the alert. A 0
+// in it marks members the box could not identify (not visible in its pid
+// namespace).
type StoppedReport struct {
V int `json:"v"`
Role string `json:"role"`
diff --git a/internal/stopevidence/reconstruct_linux.go b/internal/stopevidence/reconstruct_linux.go
index fd6e3db42..be40b6751 100644
--- a/internal/stopevidence/reconstruct_linux.go
+++ b/internal/stopevidence/reconstruct_linux.go
@@ -187,12 +187,24 @@ func stopTree(ctx context.Context, host string, open func(string) (treeGroup, er
return moves.Check{}, err
}
+// strayCheck is StepStray's verdict on one read of the agent's leaf. A pid
+// other than the agent's fails it with that pid. A 0 entry (the kernel's
+// name for a member outside this process's pid namespace) fails it too: it
+// is a process the box cannot identify, so it is never taken for the agent
+// and never for nothing.
func strayCheck(pids []int, self int) moves.Check {
+ unknown := 0
for _, p := range pids {
- if p != self {
+ switch {
+ case p == 0:
+ unknown++
+ case p != self:
return moves.Check{Reason: fmt.Sprintf("pid %d in the agent's cgroup is not the agent", p)}
}
}
+ if unknown > 0 {
+ return moves.Check{Reason: fmt.Sprintf("%d process(es) in the agent's cgroup cannot be identified", unknown)}
+ }
return moves.Check{OK: true}
}
@@ -209,7 +221,9 @@ func agentLeaf(procRoot string, self int) ([]int, error) {
return pids, nil
}
-// strays is every pid but self across the reads, sorted, each once.
+// strays is every pid but self across the reads, sorted, each once. A 0
+// is the marker for members the box could not identify (once, however many
+// there were; the step's reason carries the count).
func strays(self int, reads ...[]int) []int {
var out []int
for _, pids := range reads {
@@ -338,7 +352,11 @@ func unescapeMount(s string) string {
return b.String()
}
-// readProcs parses cgroup.procs: one pid per line.
+// readProcs parses cgroup.procs: one pid per line. The kernel lists pid 0
+// for a member that is not visible in the reader's pid namespace; it is
+// returned as 0, for the caller to treat as an unidentifiable member. Any
+// other entry that is not a plain non-negative number is an error.
+
func readProcs(file string) ([]int, error) {
raw, err := os.ReadFile(file)
if err != nil {
@@ -351,7 +369,7 @@ func readProcs(file string) ([]int, error) {
continue
}
pid, err := strconv.Atoi(line)
- if err != nil || pid <= 0 {
+ if err != nil || pid < 0 || line[0] == '+' || line[0] == '-' {
return nil, fmt.Errorf("stopevidence: %s: invalid pid %q", file, line)
}
pids = append(pids, pid)
diff --git a/internal/stopevidence/reconstruct_linux_test.go b/internal/stopevidence/reconstruct_linux_test.go
index f533a71af..c38ee88fc 100644
--- a/internal/stopevidence/reconstruct_linux_test.go
+++ b/internal/stopevidence/reconstruct_linux_test.go
@@ -70,7 +70,10 @@ func TestReconstructStopEvidenceKillsTheWholeTree(t *testing.T) {
// Step 2 against the running kernel: the leaf /proc/self/cgroup names is
// found under the cgroup2 mount /proc/self/mountinfo names, and its
// cgroup.procs holds this process. Whether StepStray passes depends on
-// what else shares the test's cgroup (alone in a fresh scope, it passes).
+// what else shares the test's cgroup (alone in a fresh scope, it passes;
+// on a CI runner the leaf holds other processes, some listed as pid 0
+// because they are outside this namespace, and it fails). The read itself
+// must succeed either way, and the step's verdict must agree with Stray.
func TestReconstructReadsTheRealOwnLeaf(t *testing.T) {
if _, ok := cgroup.ParseSelfCgroup(readOrSkip(t, "/proc/self/cgroup")); !ok {
t.Skip("no cgroup v2 leaf for this process")
@@ -89,6 +92,11 @@ func TestReconstructReadsTheRealOwnLeaf(t *testing.T) {
t.Fatal("the agent counted itself as a stray")
}
}
+ // A pass means the agent is alone; a failure must be accounted for by
+ // the other members the read found, 0 marking those it cannot name.
+ if c := rep.Steps[moves.StepStray]; c.OK != (len(rep.Stray) == 0) {
+ t.Fatalf("stray step %+v disagrees with the %d other members read: %v", c, len(rep.Stray), rep.Stray)
+ }
t.Logf("stray step %+v, %d others in the leaf", rep.Steps[moves.StepStray], len(rep.Stray))
}
@@ -318,6 +326,52 @@ func TestReconstructStrayInTheAgentsLeaf(t *testing.T) {
}
}
+// The kernel lists pid 0 in cgroup.procs for a member the reader's pid
+// namespace cannot name (a CI runner's cgroup holds such members). It is
+// a process this box cannot account for: it fails StepStray with no error,
+// is never taken for the agent, and is marked in Stray by the 0 entry.
+func TestReconstructUnidentifiableMemberIsAStray(t *testing.T) {
+ self := os.Getpid()
+ if c := strayCheck([]int{self, 0}, self); c.OK || c.Reason != "1 process(es) in the agent's cgroup cannot be identified" {
+ t.Fatalf("strayCheck with the agent and a 0: %+v", c)
+ }
+ if c := strayCheck([]int{0, self, 0}, self); c.OK || c.Reason != "2 process(es) in the agent's cgroup cannot be identified" {
+ t.Fatalf("strayCheck with two 0s: %+v", c)
+ }
+ f := newBoxFixture(t, self, 0)
+ rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil))
+ if err != nil {
+ t.Fatalf("a pid 0 entry is not a parse error: %v", err)
+ }
+ if c := rep.Steps[moves.StepStray]; c.OK || c.Reason == "" || rep.TreeStopped() {
+ t.Fatalf("stray: %+v", rep)
+ }
+ if !reflect.DeepEqual(rep.Stray, []int{0}) {
+ t.Fatalf("Stray %v", rep.Stray)
+ }
+ // Never the agent itself: a leaf of only 0s does not name the agent.
+ f.setLeaf(t, 0)
+ if _, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err == nil {
+ t.Fatal("a leaf of only pid 0 passed as the agent's")
+ }
+}
+
+// Genuinely malformed entries are still refused.
+func TestReadProcsRefusesMalformedEntries(t *testing.T) {
+ for _, body := range []string{"abc\n", "-1\n", "-0\n", "12x\n", "1 2\n"} {
+ file := filepath.Join(t.TempDir(), "cgroup.procs")
+ write(t, file, body)
+ if _, err := readProcs(file); err == nil {
+ t.Errorf("%q accepted", body)
+ }
+ }
+ file := filepath.Join(t.TempDir(), "cgroup.procs")
+ write(t, file, "7\n0\n\n9\n")
+ if got, err := readProcs(file); err != nil || !reflect.DeepEqual(got, []int{7, 0, 9}) {
+ t.Fatalf("got %v, %v", got, err)
+ }
+}
+
// The leaf is read where /proc/self/mountinfo mounts the hierarchy, so a
// leaf that names the agent but is not the agent's is an error, not a pass.
func TestReconstructRefusesALeafWithoutTheAgent(t *testing.T) {