diff --git a/cmd/mesh-controller/handacts_test.go b/cmd/mesh-controller/handacts_test.go index 7d2713d5..5cddf87a 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 0 --why stuck --cause sent-not-reported"}, + "push anchor --wait 2m0s --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/push.go b/cmd/mesh-controller/push.go index 7ff94186..a50c7a1b 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -740,7 +740,14 @@ func waitForApplied(ctx context.Context, inv *inventory.Inventory, node, digest fmt.Printf("%s applied it\n", node) return nil case inventory.OutcomeFailed: - return fmt.Errorf("%s applied what it was sent but %d resource(s) failed", node, len(doing.Failed)) + // Each failed resource by id with the machine's own words, so the answer says what to fix + // without a second look (novox/hq issue 629). + var each []string + for _, f := range doing.Failed { + each = append(each, f.ID+": "+f.Error) + } + return fmt.Errorf("%s applied what it was sent but %d resource(s) failed: %s", node, len(doing.Failed), + strings.Join(each, "; ")) case inventory.OutcomeRefused: return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused) } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index bbd9620d..9070840d 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -13,6 +13,7 @@ import ( "slices" "sort" "strings" + "time" "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/catalogue" @@ -519,9 +520,11 @@ func (a *verbArguments) commandLine() ([]string, error) { } return []string{"unpin", str("node"), str("provision")}, nil case "push": - // Sent and not waited for: the asker reads `status` for what the machine did, which is - // what a person at a shell does too. A tool call that blocked for a push's whole apply would - // time out on every machine that takes a minute, and say nothing about the ones that did not. + // 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. // 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) @@ -534,7 +537,7 @@ 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", "0"} + argv := []string{"push", n, "--wait", seatPushWait.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) @@ -1328,6 +1331,11 @@ 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 + // 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 // broker reloads, and a reload forgets every answer the bus was about to permit — so an answer diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index c7b4f6fc..5534b0e4 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -146,7 +146,8 @@ 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"}) - if err != nil || strings.Join(argv, " ") != "push g1 --wait 0 --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" { 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 98df004c..ca0eb2c3 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -164,12 +164,14 @@ func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) { } } -// A push and a build are sent, not waited for: the asker reads status, or the build's log by its -// id, for what happened. A repository given as a forge path is said to be one (issue 176). +// Acts do not block the call. A build is sent, not waited for: the asker reads the build's log by its id. +// A push answers its caller before it starts (answersFirst), so a named push waits for the machine's own +// answer without holding anybody, and the call's record says how it went (novox/hq issue 629). A +// 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 0 --why w" { - t.Fatalf("push waits: %v", argv) + 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) } 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" { diff --git a/cmd/mesh-controller/unseen_test.go b/cmd/mesh-controller/unseen_test.go index 32f9ddfd..3bf99618 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", "0", "--move", "plex", "--why", "w"}}, + []string{"push", "home", "--wait", "2m0s", "--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"}}, } {