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 failed: its walk failed: a gate on a first machine (what it carried put back), a build, a machine

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
+53 -17
View File
@@ -117,35 +117,67 @@ func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Tim
h.SaidAt.Local().Format("2006-01-02 15:04"))
}
// searchQuietFor is how long a verdict may say its setuid search is still running before that is itself the
// urgent condition: the node-engine's bound on one search, and one statement more (a node-engine states its
// health at least every five minutes) for the verdict that follows it to be heard.
const searchQuietFor = link.RootSearchBound + 5*time.Minute
// searchQuietFor is how long the controller lets an agent account's verdict wait for the node-engine's setuid
// search before that is itself the urgent condition: the engine's bound on one search (link.RootSearchBound, the
// engine's own value), counted from when this controller first saw it waiting, never from the engine's start.
const searchQuietFor = link.RootSearchBound
// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's first
// search for setuid programs, still within its bound (novox/hq ADR 0266): a fresh statement from an engine that
// judges root, holding a verdict on the account, every verdict on it healthy or not judged yet because that
// search runs — and none of those for longer than searchQuietFor. A way to root found, a search that failed or
// did not finish, any other unknown, a stale statement: false, and DA raises it.
func searchStillRunning(agent string, h inventory.NodeHealth, had bool, now time.Time) bool {
// The kinds of an agent account's verdict, for the quiet a search earns.
const (
verdictOther = iota // anything else: a stale statement, an older engine, no verdict, another unknown
verdictPending // waiting for the search, and otherwise healthy
verdictComplete // judged: healthy, or a way to root found
)
// rootVerdictKind reads a statement for the agent account (novox/hq ADR 0266): pending when every verdict on it
// is healthy or not judged yet because the engine's setuid search runs, at least one of them that; complete when
// every verdict is healthy or one found a way to root. The engine's own "since" is not read: it starts again at
// every restart of the engine.
func rootVerdictKind(agent string, h inventory.NodeHealth, had bool, now time.Time) int {
if !had || now.Sub(h.HeardAt) > verdictFreshFor || h.Contract < link.RootContract {
return false
return verdictOther
}
pending := false
pending, any := false, false
for _, r := range h.Resources {
if r.Kind != link.KindAccount || r.Target != agent || r.Root != link.RootNever {
continue
}
any = true
switch {
case r.State == link.StateHealthy:
case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending) &&
!r.Since.IsZero() && now.Sub(r.Since) <= searchQuietFor:
case r.State == link.StateUnhealthy:
return verdictComplete
case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending):
pending = true
default:
return false
return verdictOther
}
}
return pending
switch {
case !any:
return verdictOther
case pending:
return verdictPending
}
return verdictComplete
}
// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's setuid
// search, and that this controller first saw it waiting less than searchQuietFor ago — kept in the store, so a
// node-engine restarted in a loop does not keep it quiet. A complete verdict forgets when it began.
func searchStillRunning(ctx context.Context, inv *inventory.Inventory, node, agent string, h inventory.NodeHealth,
had bool, now time.Time) (bool, error) {
switch rootVerdictKind(agent, h, had, now) {
case verdictComplete:
return false, inv.RootSearchJudged(ctx, node)
case verdictPending:
since, err := inv.RootSearchPending(ctx, node, now)
if err != nil {
return false, err
}
return now.Sub(since) <= searchQuietFor, nil
}
return false, nil
}
// probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's
@@ -166,6 +198,10 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio
return nil, err
}
now := time.Now()
quiet, err := searchStillRunning(ctx, inv, n.Name, n.AgentAccount, h, had, now)
if err != nil {
return nil, err
}
confined, why := judgedConfined(n.AgentAccount, h, had, now)
if confined {
continue
@@ -174,7 +210,7 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio
// urgent condition after every restart. The agent is still not confined — ADR 0259's router reads
// agentConfined, not this — and `node show` still says not judged. Loud again once the search fails,
// runs out its bound, or the statement goes stale.
if searchStillRunning(n.AgentAccount, h, had, now) {
if quiet {
continue
}
out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root",