Count the quiet after a restart from when the controller first saw the search pending
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1791658180097457523 (novox/mesh-controller at a2a671ad into ma…

The node-engine's own since starts again at every restart, so an agent that
restarted the engine in a loop kept agent-can-become-root quiet for ever (the
review of 2026-10-09). The controller now keeps when it first saw the verdict
waiting for the setuid search (migration 0085), forgets it at the next
complete verdict, and raises once the engine's own bound has passed since;
the bound and the pending reason are read from mesh-host's rootsearch.
This commit is contained in:
jochen
2026-10-09 12:37:18 +02:00
parent d15eee61bb
commit 2e1a9abbf7
7 changed files with 143 additions and 38 deletions
+34 -15
View File
@@ -1,6 +1,7 @@
package main
import (
"github.com/novox/mesh-host/rootsearch"
"strings"
"testing"
"time"
@@ -199,10 +200,14 @@ func TestTheNodeVerbOnlyShows(t *testing.T) {
}
// After every node-engine restart its search for setuid programs runs for up to its bound, and the agent account
// is not judged until it ends. DA does not raise that as urgent while the search is within its bound — the
// account is still not confined, and `node show` still says not judged — and raises it once the search failed,
// ran out its bound, or found a way to root.
// is not judged until it ends. DA does not raise that as urgent while the search is within its bound, counted from
// when this controller first saw it waiting — never from the engine's own "since", which a restart resets, so an
// engine restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still
// says not judged; a search that failed, or a way to root found, is urgent at once.
func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
if searchQuietFor != rootsearch.Bound {
t.Fatalf("the quiet is %s and the node-engine's bound %s: they are one value", searchQuietFor, rootsearch.Bound)
}
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
@@ -215,11 +220,12 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
t.Fatal(err)
}
d := &doctor{open: open}
say := func(state, reason string, since time.Time) []conditions.Observation {
say := func(state, reason string) []conditions.Observation {
t.Helper()
// The engine's since is always now: it was just restarted.
v := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
Kind: link.KindAccount, Target: "agent", State: state, Reason: reason, Root: link.RootNever,
Account: "agent", Since: since}
Account: "agent", Since: time.Now()}
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{v}}); err != nil {
t.Fatal(err)
@@ -231,23 +237,36 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
return onlyMachine(found, "anchor")
}
running := link.ReasonRootPending + ": the search for setuid programs, started at 19:21, has not finished yet"
if found := say(link.StateUnknown, running, time.Now().Add(-2*time.Minute)); len(found) != 0 {
t.Fatalf("a search two minutes into its bound was raised: %+v", found)
healthy := func() {
t.Helper()
if found := say(link.StateHealthy, ""); len(found) != 0 {
t.Fatalf("a healthy verdict raised: %+v", found)
}
}
if found := say(link.StateUnknown, running); len(found) != 0 {
t.Fatalf("a search first seen now was raised: %+v", found)
}
if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") {
t.Fatalf("an account whose search runs was read as confined: %q", why)
}
if found := say(link.StateUnknown, running, time.Now().Add(-searchQuietFor-time.Minute)); len(found) != 1 ||
found[0].Severity != conditions.Urgent {
t.Fatalf("a search past its bound was not urgent: %+v", found)
// The engine restarted again and again, each statement's own since fresh: the controller's clock runs on.
if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil {
t.Fatal(err)
}
incomplete := "not judged (search incomplete): the search for setuid programs did not finish (last tried at 19:23: " +
"timeout); it is tried again later"
if found := say(link.StateUnknown, incomplete, time.Now().Add(-time.Minute)); len(found) != 1 ||
found[0].Severity != conditions.Urgent {
healthy() // a complete verdict forgets when the waiting began …
if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil {
t.Fatal(err) // … and this restart loop began past the bound
}
if found := say(link.StateUnknown, running); len(found) != 1 || found[0].Severity != conditions.Urgent {
t.Fatalf("a search pending past the bound, by the controller's clock, was not urgent: %+v", found)
}
healthy()
incomplete := rootsearch.ReasonIncomplete + ": the search for setuid programs did not finish (last tried at " +
"19:23: timeout); it is tried again later"
if found := say(link.StateUnknown, incomplete); len(found) != 1 || found[0].Severity != conditions.Urgent {
t.Fatalf("a search that did not finish was not urgent: %+v", found)
}
if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running, time.Now()); len(found) != 1 ||
if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running); len(found) != 1 ||
found[0].Severity != conditions.Urgent {
t.Fatalf("a way to root found while the search runs was not urgent: %+v", found)
}