From f09c5ebdec6cf98385d3f527d4e1d5a85e131982 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 12:09:43 +0000 Subject: [PATCH] push: the wait is the call's or the controller's setting push-wait, never a length in the code (issue 629) The operator's rule: a wait is never hard-coded, at most a default is configured. The push verb takes `wait`; without it the controller's setting `push-wait` (module default 2m) applies; a setting that cannot be read waits for nothing, as a push did before, and says so. --- cmd/mesh-controller/handacts_test.go | 2 +- cmd/mesh-controller/seatverbs.go | 58 +++++++++++++++++--- cmd/mesh-controller/seatverbs_schema_test.go | 3 +- cmd/mesh-controller/seatverbs_test.go | 43 ++++++++++++++- cmd/mesh-controller/unseen_test.go | 2 +- internal/catalogue/verbs.go | 6 +- module.json | 16 +++++- 7 files changed, 113 insertions(+), 17 deletions(-) diff --git a/cmd/mesh-controller/handacts_test.go b/cmd/mesh-controller/handacts_test.go index 5cddf87a..e51398eb 100644 --- a/cmd/mesh-controller/handacts_test.go +++ b/cmd/mesh-controller/handacts_test.go @@ -55,7 +55,7 @@ func TestARepairByHandCarriesItsReason(t *testing.T) { want string }{ {"push", map[string]any{"node": "anchor", "why": "stuck", "cause": "sent-not-reported"}, - "push anchor --wait 2m0s --why stuck --cause sent-not-reported"}, + "push anchor --wait 0s --why stuck --cause sent-not-reported"}, {"plans", map[string]any{"close": "plan-1", "why": "the report will not come"}, "plans close plan-1 --why the report will not come"}, {"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"}, diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 9070840d..ff43179d 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -522,9 +522,11 @@ func (a *verbArguments) commandLine() ([]string, error) { case "push": // A push to a named machine waits for that machine's own answer, so the call's record says // how it went: applied, refused with the machine's reason, or which resources failed and why - // (novox/hq issue 629). The caller is answered before the push starts (answersFirst), so the - // wait holds nobody; it is what `calls` then shows. A sweep (--behind) still does not wait: it - // sends many machines, and the slowest would hold back what is said of the others. + // (novox/hq issue 629). How long is the call's `wait`, else the controller's setting `push-wait`; + // neither given waits for nothing, as before. The caller is answered before the push starts + // (answersFirst) and the machines are released once sent, so the wait holds nobody; it is what + // `calls` then shows. A sweep (--behind) does not wait: the slowest machine would hold back + // what is said of the others. // A push through the seat is a push by hand, and says why (novox/hq to-be 45 §7). if err := need("why"); err != nil { return nil, fmt.Errorf("%w: a push by hand is a repair, recorded in the hand-act log with why", err) @@ -537,7 +539,11 @@ func (a *verbArguments) commandLine() ([]string, error) { // behind is not read here: given with a machine, it is refused as passed over — naming // a machine and asking for every machine behind are two requests, and guessing one // would push a machine nobody named, or not push one somebody did. - argv := []string{"push", n, "--wait", seatPushWait.String()} + wait, err := pushWait(str("wait")) + if err != nil { + return nil, err + } + argv := []string{"push", n, "--wait", wait.String()} // A running module's data moving is sent only when said, per module (novox/hq ADR 0217). if m := str("move"); m != "" { argv = append(argv, "--move", m) @@ -1331,10 +1337,46 @@ var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true, // 0259 §8): the router asks it before an approval. "root-free": true} -// seatPushWait is how long a push through the seat waits for the machine it names to report applying what it -// was sent (novox/hq issue 629): inside the call's bound with room for the send itself, which took two minutes -// on 2026-10-13 with four pushes at once. -const seatPushWait = 2 * time.Minute +// pushFileVar names the file the controller's push settings are written to (module.json, `push-wait`). +const pushFileVar = "MESH_PUSH_FILE" + +// pushWait is how long a push to a named machine waits for its answer (novox/hq issue 629): the call's own +// `wait` when given, else the controller's setting `push-wait`, else nothing. No length is written here: the +// setting's default is the module's, and a setting that cannot be read waits for nothing, as a push did +// before, and is said. +func pushWait(given string) (time.Duration, error) { + if strings.TrimSpace(given) != "" { + d, ok := durationSetting(given) + if !ok { + return 0, fmt.Errorf("push: wait %q is no duration: say it as \"90s\", \"2m\", or \"0\" to wait for nothing", given) + } + return d, nil + } + path := os.Getenv(pushFileVar) + if path == "" { + return 0, nil + } + raw, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "the push settings cannot be read (%v); a push waits for nothing\n", err) + return 0, nil + } + var values map[string]any + if err := json.Unmarshal(raw, &values); err != nil { + fmt.Fprintf(os.Stderr, "the push settings are not JSON (%v); a push waits for nothing\n", err) + return 0, nil + } + v, has := values["push-wait"] + if !has || v == "" { + return 0, nil + } + d, ok := durationSetting(v) + if !ok { + fmt.Fprintf(os.Stderr, "the setting push-wait=%v is no duration; a push waits for nothing\n", v) + return 0, nil + } + return d, nil +} // answersFirst is a command line whose caller is answered before it runs: a push, by its verb or // through `command`. A push sends the machine holding the bus first when its user list changed, the diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 5534b0e4..61f3bad9 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -146,8 +146,7 @@ func TestAnArgumentAVerbDoesNotDeclareIsRefused(t *testing.T) { // naming one beside behind is refused rather than one of the two guessed. func TestAPushIsOneMachineOrSaysItIsTheWholeMesh(t *testing.T) { argv, err := argvFor("push", map[string]any{"node": "g1", "why": "w"}) - // A named push waits for the machine's own answer, so the call says how it went (novox/hq issue 629). - if err != nil || strings.Join(argv, " ") != "push g1 --wait 2m0s --why w" { + if err != nil || strings.Join(argv, " ") != "push g1 --wait 0s --why w" { t.Fatalf("a named push: %v %v", argv, err) } for _, args := range []map[string]any{{"why": "w"}, {"behind": "true", "why": "w"}} { diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index ca0eb2c3..c6bf7120 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "github.com/novox/mesh-controller/internal/link" + "os" + "path/filepath" "strings" "testing" @@ -170,8 +172,8 @@ func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) { // repository given as a forge path is said to be one (issue 176). func TestActsDoNotBlockTheCall(t *testing.T) { argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"}) - if strings.Join(argv, " ") != "push one --wait 2m0s --why w" || !answersFirst(argv) { - t.Fatalf("a named push does not wait for the machine's answer, or holds its caller: %v", argv) + if strings.Join(argv, " ") != "push one --wait 0s --why w" || !answersFirst(argv) { + t.Fatalf("a named push with no setting waits, or holds its caller: %v", argv) } argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"}) if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" { @@ -462,3 +464,40 @@ func TestAQuotedValueRoundTrips(t *testing.T) { } } } + +// A named push waits as long as the call says, else as long as the controller's setting push-wait says, else +// not at all: no length is the code's own (novox/hq issue 629). +func TestAPushWaitsAsTheCallOrTheSettingSays(t *testing.T) { + file := filepath.Join(t.TempDir(), "push.json") + if err := os.WriteFile(file, []byte(`{"push-wait": "2m"}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(pushFileVar, file) + argv, err := argvFor("push", map[string]any{"node": "one", "why": "w"}) + if err != nil || strings.Join(argv, " ") != "push one --wait 2m0s --why w" { + t.Fatalf("the setting was not the default: %v %v", argv, err) + } + argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "30s"}) + if err != nil || strings.Join(argv, " ") != "push one --wait 30s --why w" { + t.Fatalf("the call's wait did not win over the setting: %v %v", argv, err) + } + argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "0"}) + if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" { + t.Fatalf("a wait of 0 did not wait for nothing: %v %v", argv, err) + } + if _, err := argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "soon"}); err == nil || + !strings.Contains(err.Error(), "no duration") { + t.Fatalf("a wait that is no duration was taken: %v", err) + } + if err := os.WriteFile(file, []byte(`{"push-wait": "later"}`), 0o600); err != nil { + t.Fatal(err) + } + argv, err = argvFor("push", map[string]any{"node": "one", "why": "w"}) + if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" { + t.Fatalf("an unreadable setting did not fall back to waiting for nothing: %v %v", argv, err) + } + argv, _ = argvFor("push", map[string]any{"why": "w"}) + if strings.Join(argv, " ") != "push --behind --wait 0 --why w" { + t.Fatalf("a sweep waits: %v", argv) + } +} diff --git a/cmd/mesh-controller/unseen_test.go b/cmd/mesh-controller/unseen_test.go index 3bf99618..b675766b 100644 --- a/cmd/mesh-controller/unseen_test.go +++ b/cmd/mesh-controller/unseen_test.go @@ -160,7 +160,7 @@ func TestTheVerbsCarryReadReplaceAndMove(t *testing.T) { {"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}}, {"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}}, {"push", map[string]any{"node": "home", "move": "plex", "why": "w"}, - []string{"push", "home", "--wait", "2m0s", "--move", "plex", "--why", "w"}}, + []string{"push", "home", "--wait", "0s", "--move", "plex", "--why", "w"}}, {"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}}, {"plan", map[string]any{"node": "home", "diff": "true"}, []string{"plan", "home", "--diff"}}, } { diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index af7cc055..8b13beae 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -217,14 +217,16 @@ var ControllerVerbs = []Verb{ Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})}, {Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " + "WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright. " + - "Answers at once that it is running, with a call id: `calls` with that id says what it sent " + - "(a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " + + "Answers at once that it is running, with a call id: `calls` with that id says what it sent and, for a " + + "machine named, how that machine applied it — applied, refused with its reason, or each failed resource — " + + "once it reports within wait (a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " + "and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " + "running module's data another directory is held and named, the others sent (novox/hq ADR 0217): read " + "`plan` with diff, and name the machine with move to send it.", Input: schema(map[string]string{ "node": "the machine's name; without it, every machine that is behind", "move": "with node: the modules whose data may move with this push, comma-separated (optional)", + "wait": "with node: how long the call waits for the machine to report how it applied what it was sent, as \"90s\" or \"2m\"; \"0\" waits for nothing; without it, the controller's setting push-wait (optional)", "behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node", "why": "why this is pushed by hand: recorded in the hand-act log", "cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)", diff --git a/module.json b/module.json index 5c98f461..a668dff0 100644 --- a/module.json +++ b/module.json @@ -35,6 +35,11 @@ "kind": "preference", "default": "10m", "why": "a busy period never holds a batch longer than this after its first merge (novox/hq ADR 0276)" + }, + "push-wait": { + "kind": "preference", + "default": "2m", + "why": "how long a push to a named machine waits for that machine's answer, so its call says how it went; a call's own wait overrides it, and 0 waits for nothing (novox/hq issue 629)" } }, "tools": [ @@ -120,6 +125,14 @@ "owner": "mesh-controller", "content": "{\n \"merge-window\": \"${setting:merge-window}\",\n \"merge-window-at-most\": \"${setting:merge-window-at-most}\"\n}\n" }, + { + "id": "push", + "type": "file", + "path": "${dir:mesh-state}/push.json", + "mode": "0600", + "owner": "mesh-controller", + "content": "{\n \"push-wait\": \"${setting:push-wait}\"\n}\n" + }, { "id": "controller", "type": "process", @@ -144,7 +157,8 @@ "MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}", "MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus", "MESH_CONTROLLER_VERSION": "${version}", - "MESH_MERGE_WINDOW_FILE": "${dir:mesh-state}/merge-window.json" + "MESH_MERGE_WINDOW_FILE": "${dir:mesh-state}/merge-window.json", + "MESH_PUSH_FILE": "${dir:mesh-state}/push.json" }, "replaces": [ "server"