mesh/merge-gate head 21d038d3397c merged into trunk 5d70eb3e35a9 — pass, with a note: builds mesh-tools, node-tools (+39 dependent(s)) → ace, g14, novox, s…
mesh/repo-check head 21d038d3397c merged into trunk 5d70eb3e35a9 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
Closing the console shut its server down with a context that never ends, so a stop or restart of the tool runner waited on any mesh_wait in flight for up to ten minutes. The server's base context is now cancelled first, ending every wait at once, with a five-second shutdown as a backstop (second review of issue 745). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
6.5 KiB
Go
178 lines
6.5 KiB
Go
package console
|
|
|
|
// mesh_wait: a session waits on the bus for one event instead of polling (novox/hq ADR 0367).
|
|
//
|
|
// **Only the families the tool runner was issued** (§2, §6): its membership names them, composed by the
|
|
// controller from the tool runner's manifest together with the grant that lets this account subscribe them.
|
|
// A name not listed there is refused here, before the bus would refuse it; no argument or setting adds one.
|
|
// **An event only wakes the session** (§4): what it says is read again from the verb that owns it.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last
|
|
// (ADR 0367 §1): four minutes and ten.
|
|
var (
|
|
WaitDefault = 4 * time.Minute
|
|
WaitLongest = 10 * time.Minute
|
|
)
|
|
|
|
func waitTool() map[string]any {
|
|
return map[string]any{"name": verbWait, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{
|
|
"subjects": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
|
"description": "one or more event families to wait on, by name, e.g. `mesh-knowledge.reviewed`; only " +
|
|
"those the mesh granted this machine's tool runner — a refusal names them"},
|
|
"filter": map[string]any{"type": "object",
|
|
"description": "fields of the event's body that must equal the given values, e.g. {\"kind\": \"decision\", \"number\": 367} (optional)"},
|
|
"timeout": map[string]any{"type": "number",
|
|
"description": fmt.Sprintf("seconds to wait: %d when absent, %d at most", int(WaitDefault.Seconds()), int(WaitLongest.Seconds()))},
|
|
}, "required": []string{"subjects"}},
|
|
"description": "Wait for the first event of the named families that matches the filter, and answer it with its " +
|
|
"family, body and time — or say it timed out. Call it in a loop from a background command, and after every " +
|
|
"answer read the state from the verb that owns it: an event only wakes you, and one published between two " +
|
|
"waits is not heard (novox/hq ADR 0367)."}
|
|
}
|
|
|
|
// waitAnswer answers one mesh_wait call.
|
|
func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[string]any {
|
|
names, err := waitNames(args["subjects"])
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
timeout, err := waitTimeout(args["timeout"])
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
filter, _ := args["filter"].(map[string]any)
|
|
if args["filter"] != nil && filter == nil {
|
|
return failure("mesh_wait's filter is an object of body fields to the values they must equal")
|
|
}
|
|
|
|
issued := map[string]string{} // name → subject
|
|
if m := s.conn.Membership(s.conn.Module()); m != nil {
|
|
for _, w := range m.Waits {
|
|
issued[w.Event] = w.Subject
|
|
}
|
|
}
|
|
if len(issued) == 0 {
|
|
return failure("this machine's tool runner was issued no event families to wait on: the mesh grants them from " +
|
|
"the tool runner's manifest (novox/hq ADR 0367 §2), and its membership names none yet")
|
|
}
|
|
byName := make([]string, 0, len(issued))
|
|
for name := range issued {
|
|
byName = append(byName, name)
|
|
}
|
|
sort.Strings(byName)
|
|
family := map[string]string{} // subject → name
|
|
var subjects []string
|
|
for _, name := range names {
|
|
subject, granted := issued[name]
|
|
if !granted {
|
|
return failure(fmt.Sprintf("mesh_wait may not wait on %q: this machine's tool runner is granted %s alone. "+
|
|
"A family is added only by a reviewed change to the tool runner's manifest (node-tools' module.json, "+
|
|
"novox/hq ADR 0367 §6); no argument, call or setting adds one", name, strings.Join(byName, ", ")))
|
|
}
|
|
if _, twice := family[subject]; !twice {
|
|
subjects = append(subjects, subject)
|
|
}
|
|
family[subject] = name
|
|
}
|
|
|
|
heard, ok, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) })
|
|
if err != nil {
|
|
return failure("mesh_wait could not wait: " + err.Error())
|
|
}
|
|
if !ok {
|
|
return answerText(map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names,
|
|
"read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"})
|
|
}
|
|
return answerText(map[string]any{
|
|
"heard": map[string]any{"subject": family[heard.Subject], "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)},
|
|
"read": "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)",
|
|
})
|
|
}
|
|
|
|
// waitNames is the families a call names: a list of names, or one name.
|
|
func waitNames(given any) ([]string, error) {
|
|
var names []string
|
|
switch v := given.(type) {
|
|
case string:
|
|
names = []string{v}
|
|
case []any:
|
|
for _, x := range v {
|
|
name, ok := x.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("mesh_wait's subjects are event names, such as mesh-knowledge.reviewed; %v is not one", x)
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
for i := range names {
|
|
names[i] = strings.TrimSpace(names[i])
|
|
}
|
|
if len(names) == 0 {
|
|
return nil, fmt.Errorf("mesh_wait names at least one event family in `subjects`, such as mesh-knowledge.reviewed")
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// waitTimeout is the call's timeout: WaitDefault when absent, refused beyond WaitLongest or below a second.
|
|
func waitTimeout(given any) (time.Duration, error) {
|
|
if given == nil {
|
|
return WaitDefault, nil
|
|
}
|
|
longest := int(WaitLongest.Seconds())
|
|
seconds, ok := given.(float64)
|
|
if !ok {
|
|
if n, isInt := given.(int); isInt {
|
|
seconds, ok = float64(n), true
|
|
}
|
|
}
|
|
if !ok || math.IsNaN(seconds) || seconds < 1 || seconds > float64(longest) {
|
|
return 0, fmt.Errorf("mesh_wait's timeout is a number of seconds from 1 to %d (novox/hq ADR 0367 §1), not %v", longest, given)
|
|
}
|
|
return time.Duration(seconds * float64(time.Second)), nil
|
|
}
|
|
|
|
// bodyMatches says whether every field the filter names equals, in the event's body, the value given.
|
|
func bodyMatches(body json.RawMessage, filter map[string]any) bool {
|
|
if len(filter) == 0 {
|
|
return true
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if json.Unmarshal(body, &fields) != nil {
|
|
return false
|
|
}
|
|
for k, want := range filter {
|
|
got, has := fields[k]
|
|
if !has {
|
|
return false
|
|
}
|
|
wanted, _ := json.Marshal(want)
|
|
if !sameJSON(got, wanted) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sameJSON compares two JSON values as values: 367 and 367.0 are one number, key order does not matter.
|
|
func sameJSON(a, b []byte) bool {
|
|
var x, y any
|
|
da, db := json.NewDecoder(bytes.NewReader(a)), json.NewDecoder(bytes.NewReader(b))
|
|
if da.Decode(&x) != nil || db.Decode(&y) != nil {
|
|
return false
|
|
}
|
|
ja, _ := json.Marshal(x)
|
|
jb, _ := json.Marshal(y)
|
|
return bytes.Equal(ja, jb)
|
|
}
|