Research 031 counted the repairs people made by hand: a push to unstick a plan waiting on a report, a controller restarted to make an object again, a plan closed, a consumer re-made from now. Each was the ordinary path taken again by someone who noticed. The healer registry makes each a registered response to one condition kind, with a budget, a settle and its event: - H1 sent-not-reported: ask the machine's node-engine to report again (mesh.node.<n>.ask.report); if it does not report what it was sent, send it again, never moving a build a policy or a plan holds back - H2 stalled: close a plan whose wait is superseded or finished - H3 holder-silent / consumer-lost: the send's own assertion of the bus's objects (issue 208's note) - H4 consumer-behind: consumer-reset, only for a consumer the stream table marks resettable (the controller's own events consumer) - H5 is the identity provider's own repair (ADR 0224 §5), registered only Success is the observation clearing the condition, never the healer; a spent budget hands the condition to the operator, urgent, with what was tried, and no healer touches it again. Every act is begun in the store before it is made (migration 0070), kept in the condition's tried as "healer Hn" and said as the seat event healer-acted; a heal is never a hand act. More than twelve acts in an hour stop every healer until an hour after the last, said urgently. Only the lease holder heals. S15 is live: a cause repaired by hand twice in a fortnight raises healer-wanted, naming the healer that was not enough where one exists. D6's far-behind finding has its own kind, consumer-behind. Nodes are granted the question; the controller's grant gains healer-acted (genesis lock in mesh-host). `healers` lists the registry, the acts and the brake; status counts the week's heals.
209 lines
7.5 KiB
Go
209 lines
7.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// `status` answered from a summary the serving controller keeps current (novox/hq to-be 45 Phase 0,
|
|
// §4's D9 and §8's health of the controller).
|
|
//
|
|
// **Asked, it was composed: every machine resolved, twice, while its caller waited.** On 2026-10-06
|
|
// the verb took eighteen seconds on a mesh of four machines, so its caller read "still running" and
|
|
// had to ask `calls` for the answer to "is the mesh alright" — the one question that must answer at
|
|
// once, and the one a self-check and a rollout gate will ask every few minutes. So the serving
|
|
// controller composes it in the background — at its start, after anything that changes what it says
|
|
// (a machine's report, a build, a verb that acts), and every minute regardless — and the verb answers
|
|
// the last composition at once, saying when it was composed and how long that took. A caller who
|
|
// needs it newer than that reads the time and asks again; nothing is answered as current that is not.
|
|
|
|
// statusEvery is how often the summary is composed with nothing having nudged it; statusSettle how
|
|
// long a nudge waits for the next, so a push answered by four machines is composed once.
|
|
var (
|
|
statusEvery = time.Minute
|
|
statusSettle = 2 * time.Second
|
|
// statusComposeWithin bounds one composition, so a store that hangs cannot stop the summary for
|
|
// good; the attempt is said as failed, and the last summary stands with its age.
|
|
statusComposeWithin = 2 * time.Minute
|
|
)
|
|
|
|
// statusSummary is the last composed `status --json`, and when.
|
|
type statusSummary struct {
|
|
compose func(context.Context) ([]byte, error)
|
|
// every, settle and within are the clocks above, read once when it is made.
|
|
every, settle, within time.Duration
|
|
|
|
mu sync.Mutex
|
|
body []byte
|
|
composedAt time.Time
|
|
took time.Duration
|
|
failed string
|
|
failedAt time.Time
|
|
started time.Time
|
|
first chan struct{} // closed when the first attempt ends, either way
|
|
|
|
nudged chan struct{}
|
|
}
|
|
|
|
func newStatusSummary(compose func(context.Context) ([]byte, error)) *statusSummary {
|
|
return &statusSummary{compose: compose, started: time.Now(), first: make(chan struct{}),
|
|
nudged: make(chan struct{}, 1), every: statusEvery, settle: statusSettle, within: statusComposeWithin}
|
|
}
|
|
|
|
// statusFrom is the serving controller's summary; nil in any other process, where `status` is
|
|
// composed when asked, as at a shell.
|
|
var statusFrom *statusSummary
|
|
|
|
// nudge asks for a composition soon. Never blocks: one pending is as good as many.
|
|
func (s *statusSummary) nudge() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
select {
|
|
case s.nudged <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// keep composes until ctx ends: now, on a nudge once things settle, and every statusEvery.
|
|
func (s *statusSummary) keep(ctx context.Context) {
|
|
once := sync.Once{}
|
|
for {
|
|
s.composeOnce(ctx)
|
|
once.Do(func() { close(s.first) })
|
|
timer := time.NewTimer(s.every)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
case <-s.nudged:
|
|
timer.Stop()
|
|
// Let what else is arriving arrive, then compose once for all of it.
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(s.settle):
|
|
}
|
|
select {
|
|
case <-s.nudged:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *statusSummary) composeOnce(ctx context.Context) {
|
|
start := time.Now()
|
|
asking, cancel := context.WithTimeout(ctx, s.within)
|
|
body, err := s.compose(asking)
|
|
cancel()
|
|
took := time.Since(start)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err != nil {
|
|
s.failed, s.failedAt = err.Error(), time.Now()
|
|
fmt.Printf("status could not be composed (after %s): %v — `status` answers the last summary, "+
|
|
"with its age\n", took.Round(time.Millisecond), err)
|
|
return
|
|
}
|
|
s.body, s.composedAt, s.took, s.failed = body, start, took, ""
|
|
}
|
|
|
|
// answer is what the `status` verb answers: the last summary at once, the same document `status
|
|
// --json` prints, with when it was composed. Before the first composition has ended it waits for it,
|
|
// but never past the caller's window; a controller that has none says so and why, rather than
|
|
// answering an empty mesh as a well one.
|
|
func (s *statusSummary) answer(ctx context.Context) (any, error) {
|
|
wait := time.NewTimer(link.AnswerWithin - time.Second)
|
|
defer wait.Stop()
|
|
select {
|
|
case <-s.first:
|
|
case <-wait.C:
|
|
case <-ctx.Done():
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.body == nil {
|
|
why := "its first composition has not finished"
|
|
if s.failed != "" {
|
|
why = "it could not be composed: " + s.failed
|
|
}
|
|
return nil, fmt.Errorf("this controller started %s ago and has no status to answer yet — %s. "+
|
|
"Ask again shortly", time.Since(s.started).Round(time.Second), why)
|
|
}
|
|
var parsed any
|
|
_ = json.Unmarshal(s.body, &parsed)
|
|
out := map[string]any{
|
|
"output": string(s.body), "ok": true, "answer": parsed,
|
|
"composed": s.composedAt.UTC().Format(time.RFC3339),
|
|
"age": time.Since(s.composedAt).Round(time.Second).String(),
|
|
"composedIn": s.took.Round(time.Millisecond).String(),
|
|
"note": "composed by the serving controller at its start, after each report, build or act, and " +
|
|
"every minute; answered at once from the last composition",
|
|
}
|
|
if s.failed != "" && s.failedAt.After(s.composedAt) {
|
|
out["lastAttemptFailed"] = fmt.Sprintf("%s: %s — this summary is the last that could be composed",
|
|
s.failedAt.UTC().Format(time.RFC3339), s.failed)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// composeStatus is `status --json`, composed in this process against its stores.
|
|
func composeStatus(open *stores) func(context.Context) ([]byte, error) {
|
|
return func(ctx context.Context) ([]byte, error) {
|
|
asked, err := theThreeQuestions(ctx, open)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return statusAsJSON(asked)
|
|
}
|
|
}
|
|
|
|
// readingVerbs are the verbs that only read; after any other, what `status` says may have changed, so the summary is
|
|
// composed again; a verb that only reads leaves it alone, or a console polling `nodes` would keep the
|
|
// controller composing for ever.
|
|
var readingVerbs = map[string]bool{
|
|
"tools": true, "calls": true, "status": true, "nodes": true, "node": true, "modules": true,
|
|
"seats": true, "builds": true, "plan": true, "queue": true, "durations": true, "hand-acts": true,
|
|
"doctor": true, "conditions": true, "healers": true,
|
|
}
|
|
|
|
// nudgingListener is the enrolment, nudging the summary when a machine said something new.
|
|
type nudgingListener struct {
|
|
link.Enrolment
|
|
summary *statusSummary
|
|
// open is the serving controller's stores, for replacing a given value after a module's first
|
|
// good start (novox/hq ADR 0228).
|
|
open *stores
|
|
}
|
|
|
|
func (l nudgingListener) Heard(ctx context.Context, report link.Report) (bool, error) {
|
|
// A declaration refused as older than the one the machine holds is counted by its writer — the
|
|
// controller epoch it claimed (novox/hq to-be 45 §6, S13) — and so is what the machine's own count
|
|
// says it refused beyond the refusals heard.
|
|
now := time.Now()
|
|
if report.StaleRefusalOf() {
|
|
link.StaleRefusals.Refused(link.Refusal{Writer: link.WriterEpoch(report.Epoch), Epoch: report.Epoch,
|
|
Receiver: report.Node, At: now})
|
|
}
|
|
if report.Ordered() {
|
|
link.StaleRefusals.Lifetime(report.Node, report.RefusedOlder, now)
|
|
}
|
|
news, err := l.Enrolment.Heard(ctx, report)
|
|
if news {
|
|
l.summary.nudge()
|
|
}
|
|
if err == nil && l.open != nil && startedWell(report) {
|
|
// Off the report's path: replacing a given value sends the machine, and a report waits for
|
|
// nothing it caused (novox/hq ADR 0228).
|
|
go replaceGiven(context.WithoutCancel(ctx), l.open, report)
|
|
}
|
|
return news, err
|
|
}
|