seat: a push to a named machine waits for the machine's answer, so its call says how it went (issue 629)
mesh/merge-gate head 6d2e4c299a34 merged into trunk 584aa2d7d7a5 — pass: builds mesh-controller → novox; no bus step; every machine composes with the chang…
mesh/repo-check head 6d2e4c299a34 merged into trunk 584aa2d7d7a5 — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

The seat ran `push <node> --wait 0`, so a push's call ended at "sent" and nobody learned whether the machine applied it. The caller is answered before a push starts (answersFirst, issue 265), so waiting holds nobody: the call now waits up to two minutes for the machine's own report and keeps it, applied, refused with the machine's reason, or each failed resource with its error.
This commit is contained in:
2026-10-13 12:03:53 +00:00
parent 584aa2d7d7
commit 6d2e4c299a
6 changed files with 30 additions and 12 deletions
+1 -1
View File
@@ -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"},
+8 -1
View File
@@ -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)
}
+12 -4
View File
@@ -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
+2 -1
View File
@@ -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"}} {
+6 -4
View File
@@ -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" {
+1 -1
View File
@@ -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"}},
} {