Merge pull request 'claude-code: render a 660 s timeout for the mesh console, so mesh_wait is not cut at a minute (issue 1871)' (#533) from fix/1871-wait-returns-early into main

This commit was merged in pull request #533.
This commit is contained in:
2026-10-28 07:46:26 +00:00
3 changed files with 55 additions and 3 deletions
+2 -2
View File
@@ -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")
} }
+24 -1
View File
@@ -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
} }