Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a9570f5e9 | ||
|
|
d871258f8b | ||
|
|
e7abe3e3e4 | ||
|
|
6cea12a023 | ||
|
|
3fb7951524 | ||
|
|
ae96afcaa8 | ||
|
|
0c63b4c906 | ||
|
|
354d62340d | ||
|
|
dea390ac97 | ||
|
|
00d155da60 | ||
|
|
89c30c44be | ||
|
|
bd65c839a8 | ||
|
|
b7a036e186 | ||
|
|
3abf7cd50b | ||
|
|
d7e8a8824e | ||
|
|
83e9ce5b19 | ||
|
|
653cead338 | ||
|
|
d1ca199265 |
@@ -45,8 +45,8 @@ whenever the node's tool runtime collects the module's tools:
|
|||||||
|
|
||||||
| file | holds |
|
| file | holds |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh` (with a `timeout` of 660000 ms, mesh_wait's longest wait and a minute, so the agent's one-minute default does not cut a wait, issue 1871), and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
|
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin, and `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` (660000) in `env` beside the operator's own, the mesh's key winning |
|
||||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
||||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
|||||||
for k := range MarketplaceKeys() {
|
for k := range MarketplaceKeys() {
|
||||||
delete(m, k)
|
delete(m, k)
|
||||||
}
|
}
|
||||||
|
// The wait bound is new since the TypeScript (issue 1871), and is held below.
|
||||||
|
if env, ok := m["env"].(map[string]any); ok {
|
||||||
|
delete(env, "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT")
|
||||||
|
if len(env) == 0 {
|
||||||
|
delete(m, "env")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m, ok := a.(map[string]any); ok && file == "managed-mcp.json" {
|
||||||
|
if servers, ok := m["mcpServers"].(map[string]any); ok {
|
||||||
|
if entry, ok := servers["mesh"].(map[string]any); ok {
|
||||||
|
delete(entry, "timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||||
if !reflect.DeepEqual(a, b) {
|
if !reflect.DeepEqual(a, b) {
|
||||||
@@ -104,6 +118,21 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
|
|||||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||||
t.Fatalf("%v", mcp.MCPServers)
|
t.Fatalf("%v", mcp.MCPServers)
|
||||||
}
|
}
|
||||||
|
// The entry bounds a call by mesh_wait's longest wait and the idle bound is laid over the operator's env
|
||||||
|
// (issue 1871): a session's wait is not cut at the agent's one-minute default.
|
||||||
|
if mcp.MCPServers["mesh"]["timeout"] != float64(660000) {
|
||||||
|
t.Errorf("the mesh entry's timeout is %v", mcp.MCPServers["mesh"]["timeout"])
|
||||||
|
}
|
||||||
|
for _, held := range []map[string]any{nil, {"CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "1", "FOO": "bar"}} {
|
||||||
|
var m struct {
|
||||||
|
Env map[string]any `json:"env"`
|
||||||
|
}
|
||||||
|
got := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"env": held}}, nil, "/h", nil, Config{})
|
||||||
|
_ = json.Unmarshal([]byte(got["managed-settings.json"]), &m)
|
||||||
|
if m.Env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] != "660000" || (held != nil && m.Env["FOO"] != "bar") {
|
||||||
|
t.Errorf("env %v rendered as %v", held, m.Env)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
||||||
t.Fatal("rendering is not deterministic")
|
t.Fatal("rendering is not deterministic")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,6 +99,27 @@ func jsonFile(v any) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MeshWaitBoundMillis is how long the agent may wait on the mesh's console for one call: mesh_wait's longest
|
||||||
|
// wait (600 s, mesh-tools node-tools console.WaitLongest) plus a minute's margin. The agent bounds an HTTP
|
||||||
|
// server's request by the largest of 60 s, the server entry's "timeout" and MCP_TIMEOUT, and a call that
|
||||||
|
// receives nothing for CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT (5 minutes by default) is aborted
|
||||||
|
// (code.claude.com/docs/en/mcp.md). Without these every mesh_wait ended "The operation timed out." at a
|
||||||
|
// minute, whatever its timeout (issue 1871).
|
||||||
|
const MeshWaitBoundMillis = 660000
|
||||||
|
|
||||||
|
// meshWaitEnv lays the idle bound into the environment the settings give every session, the mesh's key winning
|
||||||
|
// over the operator's, like the forge's keys.
|
||||||
|
func meshWaitEnv(managed map[string]any) {
|
||||||
|
env := map[string]any{}
|
||||||
|
if held, ok := managed["env"].(map[string]any); ok {
|
||||||
|
for k, v := range held {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] = strconv.Itoa(MeshWaitBoundMillis)
|
||||||
|
managed["env"] = env
|
||||||
|
}
|
||||||
|
|
||||||
// Render composes the three files. registered — what was registered through this module and applies
|
// Render composes the three files. registered — what was registered through this module and applies
|
||||||
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
||||||
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
||||||
@@ -119,7 +141,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
|||||||
servers[name] = entry
|
servers[name] = entry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console, "timeout": MeshWaitBoundMillis}
|
||||||
|
|
||||||
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
|
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
|
||||||
// through this module's tools is laid over it (novox/hq issue 340).
|
// through this module's tools is laid over it (novox/hq issue 340).
|
||||||
@@ -143,6 +165,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
|||||||
managed[key] = entries
|
managed[key] = entries
|
||||||
}
|
}
|
||||||
forgeEnv(managed, facts.ForgeCredentials)
|
forgeEnv(managed, facts.ForgeCredentials)
|
||||||
|
meshWaitEnv(managed)
|
||||||
if binding != nil && binding.Kind == "api-key" {
|
if binding != nil && binding.Kind == "api-key" {
|
||||||
managed["apiKeyHelper"] = helperPath
|
managed["apiKeyHelper"] = helperPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,6 +411,9 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
check := h.checkAt(members)
|
check := h.checkAt(members)
|
||||||
|
if !h.checkGates() {
|
||||||
|
check = unansweredWords(check) // asked with no verdict only past its bound (verdictWaits)
|
||||||
|
}
|
||||||
ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, check, now)
|
ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, check, now)
|
||||||
taken := []*Delivery{}
|
taken := []*Delivery{}
|
||||||
var reads []memberRead
|
var reads []memberRead
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
@@ -27,3 +32,24 @@ func statusOn(w *world, repo, commit string) string {
|
|||||||
defer w.forge.mu.Unlock()
|
defer w.forge.mu.Unlock()
|
||||||
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
|
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkerSays puts the checker's verdict at these heads together into its verdicts bucket, as captured on the bus
|
||||||
|
// (mesh-delivery-checker, key `<group>.<heads hash>`).
|
||||||
|
func checkerSays(w *world, group, state, failed string, heads ...sdk.Head) {
|
||||||
|
checkerSaysWith(w, group, state, failed, nil, heads...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkerSaysWith is checkerSays with more of the verdict's fields (its composition); the state "gone" deletes the key.
|
||||||
|
func checkerSaysWith(w *world, group, state, failed string, more map[string]any, heads ...sdk.Head) {
|
||||||
|
hash, _ := sdk.HeadsHash(heads)
|
||||||
|
if state == "gone" {
|
||||||
|
w.h.CheckerVerdictChanged(group+"."+hash, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := map[string]any{"v": 1, "group": group, "heads": hash, "state": state, "failed": map[string]string{"words": failed}}
|
||||||
|
for k, x := range more {
|
||||||
|
v[k] = x
|
||||||
|
}
|
||||||
|
value, _ := json.Marshal(v)
|
||||||
|
w.h.CheckerVerdictChanged(group+"."+hash, value)
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ type Holder struct {
|
|||||||
unchecked map[string]unchecked
|
unchecked map[string]unchecked
|
||||||
// checker are the checker's verdicts by the hash of the heads they judged, as its verdicts bucket holds them
|
// checker are the checker's verdicts by the hash of the heads they judged, as its verdicts bucket holds them
|
||||||
// (onestatus.go, issue 1302); runningSince when this owner first saw each running (ADR 0548 §2, turns.go).
|
// (onestatus.go, issue 1302); runningSince when this owner first saw each running (ADR 0548 §2, turns.go).
|
||||||
checker map[string]checkerVerdict
|
checker map[string]checkerVerdict
|
||||||
|
// verdictGone is when the checker's verdict at a heads hash was taken away (its key deleted): the Approve's wait for a
|
||||||
|
// verdict counts its bound from then, as from a check that starts again (verdictWaits).
|
||||||
|
verdictGone map[string]time.Time
|
||||||
runningSince map[string]time.Time
|
runningSince map[string]time.Time
|
||||||
// unmergeableSince is when the forge first said a group's member does not merge now, by group and member (issue 1275,
|
// unmergeableSince is when the forge first said a group's member does not merge now, by group and member (issue 1275,
|
||||||
// group_merge.go): within forgeMergeGrace that is the forge still working out mergeability, asked again shortly.
|
// group_merge.go): within forgeMergeGrace that is the forge still working out mergeability, asked again shortly.
|
||||||
@@ -141,7 +144,7 @@ func (h *Holder) init() {
|
|||||||
h.lostRead = map[string]time.Time{}
|
h.lostRead = map[string]time.Time{}
|
||||||
h.unchecked = map[string]unchecked{}
|
h.unchecked = map[string]unchecked{}
|
||||||
h.checker = map[string]checkerVerdict{}
|
h.checker = map[string]checkerVerdict{}
|
||||||
h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]sdk.Turn{}
|
h.runningSince, h.turnsHeld, h.verdictGone = map[string]time.Time{}, map[string]sdk.Turn{}, map[string]time.Time{}
|
||||||
h.reach = map[string]sdk.Reach{}
|
h.reach = map[string]sdk.Reach{}
|
||||||
h.started = h.Now()
|
h.started = h.Now()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
|
||||||
|
|
||||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
)
|
)
|
||||||
@@ -57,6 +58,12 @@ type checkerVerdict struct {
|
|||||||
Machine string `json:"machine,omitempty"`
|
Machine string `json:"machine,omitempty"`
|
||||||
Words string `json:"words,omitempty"`
|
Words string `json:"words,omitempty"`
|
||||||
} `json:"failed,omitzero"`
|
} `json:"failed,omitzero"`
|
||||||
|
// Composition is what the check of the machines' composition made of it (mesh-sdk delivery.Composition): its state
|
||||||
|
// not-run says the composition, and with it a placement change's answers from its nodes, was not judged.
|
||||||
|
Composition *struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Words string `json:"words,omitempty"`
|
||||||
|
} `json:"composition,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckerVerdictChanged takes a key of the checker's verdicts bucket (`<group>.<heads hash>`) put or deleted (an empty
|
// CheckerVerdictChanged takes a key of the checker's verdicts bucket (`<group>.<heads hash>`) put or deleted (an empty
|
||||||
@@ -75,9 +82,13 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
|
|||||||
if h.runningSince == nil {
|
if h.runningSince == nil {
|
||||||
h.runningSince = map[string]time.Time{}
|
h.runningSince = map[string]time.Time{}
|
||||||
}
|
}
|
||||||
|
if h.verdictGone == nil {
|
||||||
|
h.verdictGone = map[string]time.Time{}
|
||||||
|
}
|
||||||
if len(value) == 0 || string(value) == "null" {
|
if len(value) == 0 || string(value) == "null" {
|
||||||
delete(h.checker, hash)
|
delete(h.checker, hash)
|
||||||
delete(h.runningSince, hash)
|
delete(h.runningSince, hash)
|
||||||
|
h.verdictGone[hash] = h.Now()
|
||||||
h.Poke()
|
h.Poke()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,6 +104,7 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
|
|||||||
h.runningSince[v.Heads] = h.Now() // a check starts: a merge it holds says since when (ADR 0548 §2)
|
h.runningSince[v.Heads] = h.Now() // a check starts: a merge it holds says since when (ADR 0548 §2)
|
||||||
} else if v.State != checkerRunning {
|
} else if v.State != checkerRunning {
|
||||||
delete(h.runningSince, v.Heads)
|
delete(h.runningSince, v.Heads)
|
||||||
|
delete(h.verdictGone, v.Heads)
|
||||||
}
|
}
|
||||||
h.checker[v.Heads] = v
|
h.checker[v.Heads] = v
|
||||||
h.Poke()
|
h.Poke()
|
||||||
@@ -329,11 +341,11 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
|
|||||||
if ok {
|
if ok {
|
||||||
switch v.State {
|
switch v.State {
|
||||||
case checkerPass:
|
case checkerPass:
|
||||||
return checkSaid{Key: checkSaidPass, Words: "passed its check"}
|
return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v, members)}
|
||||||
case checkerRunning:
|
case checkerRunning:
|
||||||
words := "its check has no verdict yet: it is running"
|
words := "its check has no verdict yet: it is running"
|
||||||
if since, held := h.runningSince[hash]; held && !since.IsZero() {
|
if since, held := h.runningSince[hash]; held && !since.IsZero() {
|
||||||
words += " (since " + since.UTC().Format("15:04") + " UTC)"
|
words += " (since " + clockSaid(since) + ")"
|
||||||
}
|
}
|
||||||
return checkSaid{Key: checkSaidRunning, Words: words}
|
return checkSaid{Key: checkSaidRunning, Words: words}
|
||||||
default:
|
default:
|
||||||
@@ -371,3 +383,31 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
|
|||||||
}
|
}
|
||||||
return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"}
|
return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notComposed is what a passing verdict adds when its composition was not judged (mesh-sdk delivery.CompositionNotRun): a
|
||||||
|
// placement change's pass then holds no node's answer to its placement (ADR 0559 §5), which the ask says. Empty otherwise.
|
||||||
|
func notComposed(v checkerVerdict, members []*Delivery) string {
|
||||||
|
if v.Composition == nil || v.Composition.State != sdk.CompositionNotRun {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
why := firstOf(oneLine(v.Composition.Words), "it was not run")
|
||||||
|
for _, m := range members {
|
||||||
|
for _, p := range m.Paths {
|
||||||
|
if strings.HasPrefix(p, "placement/") && strings.HasSuffix(p, ".json") {
|
||||||
|
return ", but its composition was not judged (" + why + "), so no node answered its placement"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ", but its composition was not judged (" + why + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatorZone is the zone the operator reads times in, as the messenger says them (its time-zone setting).
|
||||||
|
var operatorZone = func() *time.Location {
|
||||||
|
if loc, err := time.LoadLocation("Europe/Brussels"); err == nil {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
return time.UTC
|
||||||
|
}()
|
||||||
|
|
||||||
|
// clockSaid is a time of day as the operator reads it: in their zone, named ("00:35 CET").
|
||||||
|
func clockSaid(t time.Time) string { return t.In(operatorZone).Format("15:04 MST") }
|
||||||
|
|||||||
@@ -446,9 +446,75 @@ func (h *Holder) ungatedWaits(d *Delivery) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if h.covering(d) == nil {
|
||||||
|
return h.verdictWaits(d)
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// askCheckBound is how long an Approve waits for the check's verdict at its heads while the check gates no merge: past it,
|
||||||
|
// the operator is asked anyway, and the ask says the check has not answered. The checker's own step bound is 30 minutes
|
||||||
|
// and a check runs several steps; an hour covers a whole check.
|
||||||
|
const askCheckBound = time.Hour
|
||||||
|
|
||||||
|
// waitsCheckBeforeAsking is how the wait for the check's verdict begins, as `show` and the delivery's wait say it.
|
||||||
|
const waitsCheckBeforeAsking = "waits its check before asking the operator's Approve: "
|
||||||
|
|
||||||
|
// verdictWaits is why an Approve is not asked yet while the check gates no merge: the check at the heads the ask would cover
|
||||||
|
// has no verdict (the operator, 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request").
|
||||||
|
// A pass or a failure is asked (a failure in its words: the operator may still merge it), and so is a check that has not
|
||||||
|
// answered within askCheckBound of the heads becoming ready, which the ask says. For a group the check is the one of all its
|
||||||
|
// heads together; for a placement change it ends only once every node it places answered (ADR 0559 §5). Under the lock.
|
||||||
|
func (h *Holder) verdictWaits(d *Delivery) string {
|
||||||
|
members := h.askMembers(d)
|
||||||
|
c := h.checkAt(members)
|
||||||
|
if c.Key == checkSaidPass || c.Key == checkSaidFail {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
until := h.waitSince(d, members).Add(askCheckBound)
|
||||||
|
if !h.Now().Before(until) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
h.lookAt(until, "an Approve that waits its check, asked anyway at its bound")
|
||||||
|
return waitsCheckBeforeAsking + c.Words + "; asked anyway at " + clockSaid(until) + " if it has not answered"
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitSince is when the wait for a verdict began, the moment the bound of verdictWaits counts from: the latest of the
|
||||||
|
// heads becoming ready (a member with no ready run yet, such as one re-pushed or settled ready by an event this look has
|
||||||
|
// not reached, became ready now), the check at those heads starting again, and its verdict being taken away. Under the lock.
|
||||||
|
func (h *Holder) waitSince(d *Delivery, members []*Delivery) time.Time {
|
||||||
|
now := h.Now()
|
||||||
|
since := time.Time{}
|
||||||
|
later := func(t time.Time) {
|
||||||
|
if t.After(since) {
|
||||||
|
since = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range append([]*Delivery{d}, members...) {
|
||||||
|
if m.Ready == nil {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
later(m.Ready.Seen)
|
||||||
|
}
|
||||||
|
if hash, err := sdk.HeadsHash(headsOf(members)); err == nil {
|
||||||
|
later(h.runningSince[hash])
|
||||||
|
later(h.verdictGone[hash])
|
||||||
|
}
|
||||||
|
if since.IsZero() || since.After(now) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return since
|
||||||
|
}
|
||||||
|
|
||||||
|
// unansweredWords is the check's words in an ask made past askCheckBound with no verdict: it says the check has not answered.
|
||||||
|
func unansweredWords(c checkSaid) checkSaid {
|
||||||
|
if c.Key == checkSaidPass || c.Key == checkSaidFail {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c.Words += ", and has given none in " + strconv.Itoa(int(askCheckBound.Minutes())) + " minutes, so the operator is asked anyway"
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
|
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
|
||||||
const waitsForReview = "waits for a review at "
|
const waitsForReview = "waits for a review at "
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -70,27 +69,42 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1733: the Approve's ask says the check's state at the heads it names, read from the checker's verdict there: "passed
|
// 1733: the Approve's ask says the check's state at the heads it names, read from the checker's verdict there: "passed
|
||||||
// its check" only when it passed. While the check gates no merge, the ask is made before any verdict, says that Approve
|
// its check" only when it passed. While the check gates no merge, the ask waits for the check's verdict (the operator,
|
||||||
// merges before the check ends, and is asked again in new words once the check passes or fails; a running check is no
|
// 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request"): a pass or a failure is asked,
|
||||||
// new word.
|
// the failure in its words; with no verdict nothing is asked and the delivery says it waits its check, until the bound,
|
||||||
|
// past which it is asked anyway, saying so, and asked again in new words once the check passes. The verdict is the
|
||||||
|
// checker's verdicts bucket value as captured on the bus (mesh-delivery-checker, `<group>.<heads hash>`).
|
||||||
for _, row := range []struct {
|
for _, row := range []struct {
|
||||||
name, state, failed string
|
name, state, failed, then, path string
|
||||||
gated bool
|
gated bool
|
||||||
asks int
|
notComposed bool
|
||||||
want, not []string
|
after time.Duration
|
||||||
|
asks, open int
|
||||||
|
want, not []string
|
||||||
}{
|
}{
|
||||||
{name: "gated, passed", gated: true, state: "pass", asks: 1,
|
{name: "gated, passed", gated: true, state: "pass", asks: 1,
|
||||||
want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"},
|
want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"},
|
||||||
not: []string{"before its check ends", "no verdict yet"}},
|
not: []string{"before its check ends", "no verdict yet"}},
|
||||||
{name: "ungated, no verdict", asks: 1,
|
{name: "ungated, no verdict", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 15:00 CEST if"}},
|
||||||
want: []string{"its check has no verdict yet", "Approve merges it into main at " + readyHead[:12] + " now, before its check ends"},
|
{name: "ungated, running", state: "running", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running"}},
|
||||||
not: []string{"passed"}},
|
{name: "ungated, running past its bound", state: "running", after: askCheckBound, asks: 1,
|
||||||
{name: "ungated, running", state: "running", asks: 1,
|
want: []string{"its check has no verdict yet: it is running", "has given none in 60 minutes, so the operator is asked anyway",
|
||||||
want: []string{"its check has no verdict yet", "before its check ends"}, not: []string{"passed"}},
|
"Approve merges it into main at " + readyHead[:12] + " now, before its check ends"}, not: []string{"passed"}},
|
||||||
{name: "ungated, then passed", state: "pass", asks: 2,
|
{name: "ungated, past its bound, then passed", state: "running", after: askCheckBound, then: "pass", asks: 2,
|
||||||
|
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends", "anyway"}},
|
||||||
|
{name: "ungated, passed", state: "pass", asks: 1,
|
||||||
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends"}},
|
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends"}},
|
||||||
{name: "ungated, then failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 2,
|
// A pass that turns back into a check with no verdict (a check started again, its verdict taken away) past the bound
|
||||||
want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed"}},
|
// withdraws the ask, and the bound counts again from that moment: nothing is asked at once as unanswered.
|
||||||
|
{name: "ungated, passed, running again past its bound", state: "pass", after: askCheckBound, then: "running", asks: 1, open: -1,
|
||||||
|
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running (since 15:00 CEST); asked anyway at 16:00 CEST"}},
|
||||||
|
{name: "ungated, passed, its verdict gone past its bound", state: "pass", after: askCheckBound, then: "gone", asks: 1, open: -1,
|
||||||
|
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 16:00 CEST"}},
|
||||||
|
// A placement change's pass with its composition not judged holds no node's answer, and the ask says so (ADR 0559 §5).
|
||||||
|
{name: "ungated, placement passed uncomposed", state: "pass", path: "placement/node-a.json", notComposed: true, asks: 1,
|
||||||
|
want: []string{"passed its check, but its composition was not judged (the assigner is not running), so no node answered its placement"}},
|
||||||
|
{name: "ungated, failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 1,
|
||||||
|
want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed", "anyway"}},
|
||||||
} {
|
} {
|
||||||
t.Run(row.name, func(t *testing.T) {
|
t.Run(row.name, func(t *testing.T) {
|
||||||
v := newWorld(t)
|
v := newWorld(t)
|
||||||
@@ -98,26 +112,46 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
|||||||
ungated(v)
|
ungated(v)
|
||||||
}
|
}
|
||||||
v.op.auto = false
|
v.op.auto = false
|
||||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
p := pr("novox/app", 7, readyHead, "feat/x", firstOf(row.path, "src/a.go"))
|
||||||
p.Title = "Fix it"
|
p.Title = "Fix it"
|
||||||
v.h.PullUpdated(p)
|
v.h.PullUpdated(p)
|
||||||
put := func() {
|
head := headOf("novox/app", 7, readyHead)
|
||||||
hash, _ := sdk.HeadsHash([]sdk.Head{headOf("novox/app", 7, readyHead)})
|
|
||||||
value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "state": row.state,
|
|
||||||
"failed": map[string]string{"words": row.failed}})
|
|
||||||
v.h.CheckerVerdictChanged("g."+hash, value)
|
|
||||||
}
|
|
||||||
if row.gated {
|
if row.gated {
|
||||||
v.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
v.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||||
}
|
}
|
||||||
v.settleAll()
|
v.settleAll()
|
||||||
if !row.gated && row.state != "" {
|
if !row.gated && row.state != "" {
|
||||||
put()
|
var more map[string]any
|
||||||
|
if row.notComposed {
|
||||||
|
more = map[string]any{"composition": map[string]string{"state": "not-run", "words": "the assigner is not running"}}
|
||||||
|
}
|
||||||
|
checkerSaysWith(v, "g", row.state, row.failed, more, head)
|
||||||
|
v.settleAll()
|
||||||
|
}
|
||||||
|
if row.after > 0 {
|
||||||
|
v.later(row.after)
|
||||||
|
v.settleAll()
|
||||||
|
}
|
||||||
|
if row.then != "" {
|
||||||
|
checkerSays(v, "g", row.then, row.failed, head)
|
||||||
v.settleAll()
|
v.settleAll()
|
||||||
}
|
}
|
||||||
open := v.op.open()
|
open := v.op.open()
|
||||||
if len(open) != 1 || v.op.count() != row.asks {
|
wantOpen := min(row.asks, 1)
|
||||||
t.Fatalf("open asks %v, asked %d times; wanted one open, asked %d times", open, v.op.count(), row.asks)
|
if row.open < 0 {
|
||||||
|
wantOpen = 0
|
||||||
|
}
|
||||||
|
if v.op.count() != row.asks || len(open) != wantOpen {
|
||||||
|
t.Fatalf("open asks %v, asked %d times; wanted asked %d times, %d open", open, v.op.count(), row.asks, wantOpen)
|
||||||
|
}
|
||||||
|
if wantOpen == 0 {
|
||||||
|
d := v.delivery(IDOf("novox/app", readyHead))
|
||||||
|
for _, w := range row.want {
|
||||||
|
if d.Ready == nil || !strings.Contains(d.Ready.Waits, w) {
|
||||||
|
t.Fatalf("the delivery's wait does not say %q: %+v", w, d.Ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
e := v.op.ask(open[0]).Explanation
|
e := v.op.ask(open[0]).Explanation
|
||||||
for _, w := range row.want {
|
for _, w := range row.want {
|
||||||
@@ -146,8 +180,8 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
|||||||
u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||||
u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go"))
|
u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go"))
|
||||||
u.settleAll()
|
u.settleAll()
|
||||||
if u.op.count() != 2 || u.state(IDOf("novox/app", readyHead)) != Ready {
|
if u.op.count() != 0 || u.state(IDOf("novox/app", readyHead)) != Ready {
|
||||||
t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s", u.op.count(), u.state(IDOf("novox/app", readyHead)))
|
t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s; wanted ready and not asked", u.op.count(), u.state(IDOf("novox/app", readyHead)))
|
||||||
}
|
}
|
||||||
if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates {
|
if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates {
|
||||||
t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening"))
|
t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening"))
|
||||||
@@ -157,8 +191,12 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
|||||||
t.Fatalf("show does not say the check gates no merge: %v", shown)
|
t.Fatalf("show does not say the check gates no merge: %v", shown)
|
||||||
}
|
}
|
||||||
u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1"))
|
u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1"))
|
||||||
|
checkerSays(u, "feat/y", "fail", "TestB", headOf("novox/app", 8, other))
|
||||||
u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||||
u.settleAll()
|
u.settleAll()
|
||||||
|
if u.op.count() != 1 {
|
||||||
|
t.Fatalf("ungated, a failed verdict is asked once (the check gates no merge): %d asks", u.op.count())
|
||||||
|
}
|
||||||
d = u.delivery(IDOf("novox/app", other))
|
d = u.delivery(IDOf("novox/app", other))
|
||||||
if d.State != Ready || d.Check == nil || d.Check.Passes() {
|
if d.State != Ready || d.Check == nil || d.Check.Passes() {
|
||||||
t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check)
|
t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check)
|
||||||
@@ -323,14 +361,44 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
|
|||||||
u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||||
u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||||
u.settleAll()
|
u.settleAll()
|
||||||
|
group := []sdk.Head{headOf("novox/mesh-controller", 1, ctlHead), headOf("novox/mesh-catalog", 2, catHead)}
|
||||||
|
checkerSays(u, "feat/x", "running", "", group...)
|
||||||
|
u.settleAll()
|
||||||
|
// The group's check is the one of its heads together: while it runs, nothing is asked and each member says it waits.
|
||||||
|
if d := u.delivery(IDOf("novox/mesh-catalog", catHead)); u.op.count() != 0 || d.Ready == nil ||
|
||||||
|
!strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking+"its check has no verdict yet: it is running") {
|
||||||
|
t.Fatalf("ungated, the group is asked %d times while its check runs; wanted none, and the wait said: %+v", u.op.count(), d.Ready)
|
||||||
|
}
|
||||||
|
checkerSays(u, "feat/x", "pass", "", group...)
|
||||||
|
u.settleAll()
|
||||||
if u.op.count() != 1 {
|
if u.op.count() != 1 {
|
||||||
t.Fatalf("ungated, the group is asked %d times; wanted once", u.op.count())
|
t.Fatalf("ungated, the group is asked %d times once its check passed; wanted once", u.op.count())
|
||||||
}
|
}
|
||||||
// 1733: the group's ask claims no pass it has not had.
|
if e := u.op.ask(u.op.open()[0]).Explanation; !strings.Contains(e, "2 pull requests, passed its check. Approve merges all of them into main in one pass, at the heads named;") {
|
||||||
if e := u.op.ask(u.op.open()[0]).Explanation; strings.Contains(e, "passed") ||
|
t.Fatalf("ungated, the group's ask does not say its check passed:\n%s", e)
|
||||||
!strings.Contains(e, "2 pull requests: its check has no verdict yet. Approve merges all of them into main in one pass, at the heads named, now, before its check ends;") {
|
|
||||||
t.Fatalf("ungated, the group's ask does not say its check has no verdict yet:\n%s", e)
|
|
||||||
}
|
}
|
||||||
|
// A member re-pushed past the bound after its sibling became ready: the bound counts from the new head, so nothing is
|
||||||
|
// asked as unanswered at once (review round 1).
|
||||||
|
r := newWorld(t)
|
||||||
|
ungated(r)
|
||||||
|
r.op.auto = false
|
||||||
|
const ctlHead2 = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"
|
||||||
|
r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||||
|
r.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||||
|
r.settleAll()
|
||||||
|
r.later(askCheckBound + time.Minute)
|
||||||
|
r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead2, "feat/x", "internal/a.go"))
|
||||||
|
// Made ready by a handler, before the look gives it a run: no verdict and no status at the new heads, so only the rule
|
||||||
|
// that a member with no run counts as ready now keeps the older sibling's time from asking it as unanswered.
|
||||||
|
r.h.mu.Lock()
|
||||||
|
r.h.ungate(r.h.deliveries[IDOf("novox/mesh-controller", ctlHead2)], r.h.Now())
|
||||||
|
r.h.mu.Unlock()
|
||||||
|
r.settleAll()
|
||||||
|
if d := r.delivery(IDOf("novox/mesh-catalog", catHead)); r.op.count() != 0 || d.Ready == nil ||
|
||||||
|
!strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking) {
|
||||||
|
t.Fatalf("a member re-pushed past the bound: asked %d times, wait %+v; wanted none asked and the wait said", r.op.count(), d.Ready)
|
||||||
|
}
|
||||||
|
|
||||||
u.op.answer(u.op.open()[0], OptionApprove)
|
u.op.answer(u.op.open()[0], OptionApprove)
|
||||||
u.settleAll()
|
u.settleAll()
|
||||||
if len(u.forge.merges) != 2 {
|
if len(u.forge.merges) != 2 {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
"check-gates-merge": {
|
"check-gates-merge": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready and its Approve asked at once, with no verdict waited on; no merge is refused for a check judged on an older trunk; a verdict that comes is shown on the pull request and the delivery, never waited on; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.",
|
"description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready, and its Approve is asked once the check at its heads (a group: of all its heads together) has a verdict, a pass or a failure said in the ask, or after 60 minutes with none, said so (the operator, 2026-10-27 23:54: \"if you don't want me to approve it, don't send the approval request\"); an approved merge waits for no verdict; no merge is refused for a check judged on an older trunk; a later verdict is shown on the pull request and the delivery, and asks a pending Approve again in its words; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.",
|
||||||
"trusted": false,
|
"trusted": false,
|
||||||
"default": true
|
"default": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1130,6 +1130,8 @@ func (h *Holder) record(s Sent) {
|
|||||||
if h.Store != nil {
|
if h.Store != nil {
|
||||||
if err := h.Store.PutRecent(recent); err != nil {
|
if err := h.Store.PutRecent(recent); err != nil {
|
||||||
h.noteStore(err)
|
h.noteStore(err)
|
||||||
|
} else {
|
||||||
|
h.storeWritten()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1143,6 +1145,20 @@ func (h *Holder) persist(r *Record) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if err := h.Store.Put(c); err != nil {
|
if err := h.Store.Put(c); err != nil {
|
||||||
h.noteStore(err)
|
h.noteStore(err)
|
||||||
|
} else {
|
||||||
|
h.storeWritten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeWritten clears a state problem once a write succeeds again: the status says what holds now, never a
|
||||||
|
// failure that passed (issue 1507's reading of 2026-10-28: a verdict kept a single failure of the day before).
|
||||||
|
func (h *Holder) storeWritten() {
|
||||||
|
h.mu.Lock()
|
||||||
|
was := h.storeErr
|
||||||
|
h.storeErr = ""
|
||||||
|
h.mu.Unlock()
|
||||||
|
if was != "" {
|
||||||
|
h.Logf("[messenger] writes its state again (it could not: %s)", was)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,9 +71,14 @@ type memStore struct {
|
|||||||
passes map[string]Pass
|
passes map[string]Pass
|
||||||
// puts counts the writes, so a write of bytes identical to what is stored is still seen (issue 422's review).
|
// puts counts the writes, so a write of bytes identical to what is stored is still seen (issue 422's review).
|
||||||
puts int
|
puts int
|
||||||
|
// fail, when set, refuses every write of a record or of the sends, as the bus does with no responders.
|
||||||
|
fail error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *memStore) Put(r Record) error {
|
func (m *memStore) Put(r Record) error {
|
||||||
|
if m.fail != nil {
|
||||||
|
return m.fail
|
||||||
|
}
|
||||||
if m.recs == nil {
|
if m.recs == nil {
|
||||||
m.recs = map[string]Record{}
|
m.recs = map[string]Record{}
|
||||||
}
|
}
|
||||||
@@ -89,7 +94,13 @@ func (m *memStore) All() ([]Record, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
func (m *memStore) PutRecent(s []Sent) error { m.recent = s; return nil }
|
func (m *memStore) PutRecent(s []Sent) error {
|
||||||
|
if m.fail != nil {
|
||||||
|
return m.fail
|
||||||
|
}
|
||||||
|
m.recent = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (m *memStore) PutWalk(w Walk) error {
|
func (m *memStore) PutWalk(w Walk) error {
|
||||||
if m.walks == nil {
|
if m.walks == nil {
|
||||||
m.walks = map[string]Walk{}
|
m.walks = map[string]Walk{}
|
||||||
@@ -431,15 +442,21 @@ func TestEscalationIsSaidOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
||||||
h, tg, _, c, _ := newHolder(t)
|
h, tg, _, c, store := newHolder(t)
|
||||||
h.Desk = nil
|
h.Desk = nil
|
||||||
tg.fail = errors.New("telegram sendMessage: cannot connect (dial)")
|
tg.fail = errors.New("telegram sendMessage: cannot connect (dial)")
|
||||||
|
// The state could not be written either, once: said while it lasts, and no longer once a write succeeds
|
||||||
|
// (issue 1507's reading of 2026-10-28: a verdict kept a day-old "no responders").
|
||||||
|
store.fail = errors.New("nats: no responders available for request")
|
||||||
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent"))
|
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent"))
|
||||||
st := h.Status("listening")
|
st := h.Status("listening")
|
||||||
if !strings.HasPrefix(st.Verdict, "CANNOT SEND") || len(st.Unsent) != 1 || st.Channels[0].LastError == "" {
|
if !strings.HasPrefix(st.Verdict, "CANNOT SEND") || len(st.Unsent) != 1 || st.Channels[0].LastError == "" {
|
||||||
t.Fatalf("status: %+v", st)
|
t.Fatalf("status: %+v", st)
|
||||||
}
|
}
|
||||||
tg.fail = nil
|
if st.StateProblem == "" {
|
||||||
|
t.Fatalf("a failed state write is not said: %+v", st)
|
||||||
|
}
|
||||||
|
tg.fail, store.fail = nil, nil
|
||||||
h.Flush()
|
h.Flush()
|
||||||
if len(tg.sends) != 0 {
|
if len(tg.sends) != 0 {
|
||||||
t.Fatalf("tried again before a minute")
|
t.Fatalf("tried again before a minute")
|
||||||
@@ -449,6 +466,9 @@ func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
|||||||
if len(tg.sends) != 1 || h.Status("listening").Verdict != "ok" {
|
if len(tg.sends) != 1 || h.Status("listening").Verdict != "ok" {
|
||||||
t.Fatalf("not retried: %d, %s", len(tg.sends), h.Status("listening").Verdict)
|
t.Fatalf("not retried: %d, %s", len(tg.sends), h.Status("listening").Verdict)
|
||||||
}
|
}
|
||||||
|
if p := h.Status("listening").StateProblem; p != "" {
|
||||||
|
t.Fatalf("a state write that succeeded again still says the old failure: %s", p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) {
|
func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) {
|
||||||
|
|||||||
@@ -677,7 +677,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
|||||||
Run: func(map[string]any) (any, error) { return h.Open(), nil }},
|
Run: func(map[string]any) (any, error) { return h.Open(), nil }},
|
||||||
{Name: "operator-channel.history",
|
{Name: "operator-channel.history",
|
||||||
Description: "What was said to the operator lately, newest first — each send, digest, edit and failure with its " +
|
Description: "What was said to the operator lately, newest first — each send, digest, edit and failure with its " +
|
||||||
"channel, key and outcome — and every message refused for carrying an address, a path or a secret.",
|
"channel, key and outcome — every message refused for carrying an address, a path or a secret, and what " +
|
||||||
|
"waits to be sent on each channel, since when and when its burst window ends.",
|
||||||
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many of each (default 50)"}},
|
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many of each (default 50)"}},
|
||||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }},
|
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }},
|
||||||
{Name: "operator-channel.notify",
|
{Name: "operator-channel.notify",
|
||||||
@@ -750,9 +751,10 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
|||||||
return h.Unlink(kind)
|
return h.Unlink(kind)
|
||||||
}},
|
}},
|
||||||
{Name: "messenger_recent",
|
{Name: "messenger_recent",
|
||||||
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — and the refusals.",
|
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — the refusals, " +
|
||||||
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
|
"and what waits to be sent: each message held on a channel (its burst window is 30 s), since when and when its window ends.",
|
||||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
|
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
|
||||||
|
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
|
||||||
{Name: "messenger_test",
|
{Name: "messenger_test",
|
||||||
Description: "Send a test message now, to one channel kind or every one (default), through the content rule " +
|
Description: "Send a test message now, to one channel kind or every one (default), through the content rule " +
|
||||||
"and the cap: proves a channel reaches the operator. Answers per channel: put on its queue, or why not.",
|
"and the cap: proves a channel reaches the operator. Answers per channel: put on its queue, or why not.",
|
||||||
|
|||||||
@@ -66,3 +66,40 @@ func TestAnAskAlreadySentIsNotSentAgainAfterARestart(t *testing.T) {
|
|||||||
t.Fatalf("sent again after a restart: %d", len(tg.sends))
|
t.Fatalf("sent again after a restart: %d", len(tg.sends))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue 1507, read again 2026-10-28: three asks taken at 08:50:03-08:50:14 went out together at 08:50:33, at the
|
||||||
|
// end of the first one's burst window; a reading of the history in between showed no send and no refusal, and the
|
||||||
|
// asks read as lost. What waits in an outbox is in the history, with when its window ends, until it is sent.
|
||||||
|
func TestTheHistorySaysAnAskWaitingForItsBurstWindow(t *testing.T) {
|
||||||
|
h, tg, _, c, store, _ := newRouter(t)
|
||||||
|
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||||
|
submit(t, h, "mesh-config", heldAsk(c, "cfg-1"))
|
||||||
|
waiting := func() []Waiting { return h.History(20)["waiting"].([]Waiting) }
|
||||||
|
for _, step := range []struct {
|
||||||
|
name string
|
||||||
|
pass time.Duration
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"taken, inside its window", 0, 1},
|
||||||
|
{"its window passed and it was sent", BurstWindow + time.Second, 0},
|
||||||
|
} {
|
||||||
|
c.pass(step.pass)
|
||||||
|
stand(h, c)
|
||||||
|
h.Flush()
|
||||||
|
w := waiting()
|
||||||
|
if len(w) != step.want {
|
||||||
|
t.Fatalf("%s: %d waiting, want %d: %+v", step.name, len(w), step.want, w)
|
||||||
|
}
|
||||||
|
if step.want == 1 {
|
||||||
|
if w[0].Key != "ask.mesh-config.cfg-1" || w[0].Channel != tg.Name() || w[0].Due == "" {
|
||||||
|
t.Fatalf("%s: waiting %+v, want the ask on %s with when its window ends", step.name, w[0], tg.Name())
|
||||||
|
}
|
||||||
|
if len(tg.sends) != 0 {
|
||||||
|
t.Fatalf("%s: sent before its window ended", step.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tg.sends) != 1 {
|
||||||
|
t.Fatalf("sent %d times, want once", len(tg.sends))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -214,7 +214,36 @@ func (h *Holder) History(limit int) map[string]any {
|
|||||||
for i := len(h.refusals) - 1; i >= 0 && len(refusals) < limit; i-- {
|
for i := len(h.refusals) - 1; i >= 0 && len(refusals) < limit; i-- {
|
||||||
refusals = append(refusals, h.refusals[i])
|
refusals = append(refusals, h.refusals[i])
|
||||||
}
|
}
|
||||||
return map[string]any{"sends": sends, "refusals": refusals}
|
return map[string]any{"sends": sends, "refusals": refusals, "waiting": h.waitingNow()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waiting is one message held in a channel's outbox, not sent yet: history says it, so a message held for its
|
||||||
|
// burst window reads as waiting and not as never sent (issue 1507, its reading of 2026-10-28).
|
||||||
|
type Waiting struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
What string `json:"what"`
|
||||||
|
Since string `json:"since"`
|
||||||
|
// Due is when its burst window ends: it goes out at the first flush from then, unless a rule holds it longer.
|
||||||
|
Due string `json:"window_ends"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitingNow is every message held in an outbox, oldest first. Called with mu held.
|
||||||
|
func (h *Holder) waitingNow() []Waiting {
|
||||||
|
out := []Waiting{}
|
||||||
|
for ch, list := range h.outbox {
|
||||||
|
for _, it := range list {
|
||||||
|
out = append(out, Waiting{Channel: ch, Key: it.Key, What: it.What,
|
||||||
|
Since: it.At.Format(time.RFC3339Nano), Due: it.At.Add(BurstWindow).Format(time.RFC3339Nano)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Since != out[j].Since {
|
||||||
|
return out[i].Since < out[j].Since
|
||||||
|
}
|
||||||
|
return out[i].Channel < out[j].Channel
|
||||||
|
})
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test sends a test message on one channel or both, through the content rule and the cap.
|
// Test sends a test message on one channel or both, through the content rule and the cap.
|
||||||
|
|||||||
Reference in New Issue
Block a user