Review a long record within a bound sized to it, and let a proposal waiting on its author leave the knowledge base healthy (hq issue 508)
mesh/merge-gate pass: builds claude-code, mesh-knowledge → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/merge-gate pass: builds claude-code, mesh-knowledge → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A judgement over one bus call's 30 seconds is collected by its ticket from claude-code, so an eleven-clause record can be reviewed; a proposal out of review tries is told to the operator, not raised as the module's fault, so it no longer holds the module's own rollouts.
This commit is contained in:
@@ -199,7 +199,10 @@ only a language model can, for another module — the issue tracker's duplicate
|
||||
its own), in an empty directory, the question on its input: no built-in tools, no tool servers, plugins, hooks or
|
||||
instruction files, no session kept, so it can only answer. It answers the structured answer, the model, the node
|
||||
and the seconds, within 25 seconds — the process is ended past that, under the bus's 30 — and one at a time on a
|
||||
node. Refused: an empty question or one over 64 KiB, a schema that is not an object, a model not `sonnet`, `haiku`
|
||||
node. A caller may ask a longer bound with `within` (seconds, at most 120), for a long question (hq issue 508): a
|
||||
judgement not done in 20 seconds is then answered `pending` with a `ticket`, runs on to its bound, and
|
||||
`claude_code_judged` (`ticket`) collects it, waiting up to 20 seconds a call. A ticket is answered once; an answer
|
||||
nobody collects is dropped ten minutes after its bound, and none survives a restart of the module. Refused: an empty question or one over 64 KiB, a schema that is not an object, a model not `sonnet`, `haiku`
|
||||
or `opus`, and a node holding no licence. The question is never logged.
|
||||
|
||||
## Settings
|
||||
|
||||
@@ -12,12 +12,15 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -27,6 +30,14 @@ const (
|
||||
// JudgeBound is how long one judgement may take, waiting for another included: under the bus's 30 seconds
|
||||
// a tool call is answered in, so the caller hears this module's refusal rather than a timeout.
|
||||
JudgeBound = 25 * time.Second
|
||||
// MostJudgeBound is the longest bound a caller may ask with within: a long record's review takes longer than
|
||||
// one bus call allows (novox/hq issue 508), and is then collected by its ticket.
|
||||
MostJudgeBound = 120 * time.Second
|
||||
// JudgeWait is how long one call waits for a judgement asked with a bound past JudgeBound before it answers
|
||||
// the ticket: well under the bus's 30 seconds, so the call is always answered.
|
||||
JudgeWait = 20 * time.Second
|
||||
// JudgedKept is how long an answer nobody collected is kept past its judgement's bound.
|
||||
JudgedKept = 10 * time.Minute
|
||||
// MostQuestion is the largest question taken.
|
||||
MostQuestion = 64 << 10
|
||||
// MostSchema is the largest answer schema taken.
|
||||
@@ -43,14 +54,29 @@ type JudgeAsk struct {
|
||||
Question string
|
||||
Schema map[string]any
|
||||
Model string
|
||||
// Within is the bound the caller asks; JudgeBound when zero. Past JudgeBound the call answers a ticket if the
|
||||
// judgement is not done within JudgeWait, and claude_code_judged collects it (novox/hq issue 508).
|
||||
Within time.Duration
|
||||
}
|
||||
|
||||
// Judged is one answer.
|
||||
type Judged struct {
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
Answer json.RawMessage `json:"answer,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Node string `json:"node"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
// Ticket names a judgement asked with a bound past JudgeBound; Pending says it is not done yet, and
|
||||
// claude_code_judged with the ticket collects it.
|
||||
Ticket string `json:"ticket,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// running is one judgement asked with a bound past JudgeBound, until it is collected.
|
||||
type running struct {
|
||||
done chan struct{}
|
||||
out *Judged
|
||||
err error
|
||||
until time.Time
|
||||
}
|
||||
|
||||
// Judge runs judgements on this node, one at a time.
|
||||
@@ -63,7 +89,13 @@ type Judge struct {
|
||||
Now func() time.Time
|
||||
// Bound is how long one judgement may take; JudgeBound unless a test says less.
|
||||
Bound time.Duration
|
||||
turn chan struct{}
|
||||
// Wait is how long a call waits for a long judgement before it answers its ticket; JudgeWait unless a test
|
||||
// says less.
|
||||
Wait time.Duration
|
||||
turn chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
tickets map[string]*running
|
||||
}
|
||||
|
||||
// NewJudge is the judge of this node.
|
||||
@@ -118,11 +150,25 @@ func (j *Judge) Judge(a JudgeAsk) (*Judged, error) {
|
||||
"bindings say which node holds which", j.P.Node)
|
||||
}
|
||||
|
||||
start := j.Now()
|
||||
bound := j.Bound
|
||||
if bound <= 0 {
|
||||
bound = JudgeBound
|
||||
}
|
||||
if a.Within > MostJudgeBound {
|
||||
return nil, fmt.Errorf("within is %s; a judgement takes at most %s", a.Within, MostJudgeBound)
|
||||
}
|
||||
if a.Within > bound {
|
||||
return j.start(string(schema), model, question, a.Within)
|
||||
}
|
||||
if a.Within > 0 {
|
||||
bound = a.Within
|
||||
}
|
||||
return j.run(string(schema), model, question, bound)
|
||||
}
|
||||
|
||||
// run is one judgement within its bound, waiting for its turn included.
|
||||
func (j *Judge) run(schema, model, question string, bound time.Duration) (*Judged, error) {
|
||||
start := j.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), bound)
|
||||
defer cancel()
|
||||
select {
|
||||
@@ -131,7 +177,7 @@ func (j *Judge) Judge(a JudgeAsk) (*Judged, error) {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("another judgement held %s's agent for all of %s; ask again", j.P.Node, bound)
|
||||
}
|
||||
out, err := j.Exec(ctx, j.Argv(string(schema), model), []byte(question))
|
||||
out, err := j.Exec(ctx, j.Argv(schema, model), []byte(question))
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("the agent on %s did not answer within %s; the judgement was ended", j.P.Node, bound)
|
||||
}
|
||||
@@ -145,6 +191,68 @@ func (j *Judge) Judge(a JudgeAsk) (*Judged, error) {
|
||||
return &Judged{Answer: answer, Model: model, Node: j.P.Node, Seconds: j.Now().Sub(start).Round(100 * time.Millisecond).Seconds()}, nil
|
||||
}
|
||||
|
||||
// start runs a judgement whose bound is past what one bus call allows, and waits for it as long as a call may: its
|
||||
// answer when it is done by then, its ticket otherwise (novox/hq issue 508).
|
||||
func (j *Judge) start(schema, model, question string, bound time.Duration) (*Judged, error) {
|
||||
var b [12]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
ticket := hex.EncodeToString(b[:])
|
||||
r := &running{done: make(chan struct{}), until: j.Now().Add(bound + JudgedKept)}
|
||||
j.mu.Lock()
|
||||
j.forget()
|
||||
if j.tickets == nil {
|
||||
j.tickets = map[string]*running{}
|
||||
}
|
||||
j.tickets[ticket] = r
|
||||
j.mu.Unlock()
|
||||
go func() {
|
||||
r.out, r.err = j.run(schema, model, question, bound)
|
||||
close(r.done)
|
||||
}()
|
||||
return j.Collect(ticket)
|
||||
}
|
||||
|
||||
// Collect waits as long as a call may for the judgement under a ticket: its answer, why it failed, or that it is
|
||||
// still pending. A ticket is answered once; an answer nobody collects is kept JudgedKept past its bound.
|
||||
func (j *Judge) Collect(ticket string) (*Judged, error) {
|
||||
j.mu.Lock()
|
||||
j.forget()
|
||||
r := j.tickets[ticket]
|
||||
j.mu.Unlock()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("no judgement on %s under ticket %q: it was collected, kept past its time, or this module "+
|
||||
"restarted since; ask the judgement again", j.P.Node, ticket)
|
||||
}
|
||||
wait := j.Wait
|
||||
if wait <= 0 {
|
||||
wait = JudgeWait
|
||||
}
|
||||
select {
|
||||
case <-r.done:
|
||||
case <-time.After(wait):
|
||||
return &Judged{Node: j.P.Node, Ticket: ticket, Pending: true}, nil
|
||||
}
|
||||
j.mu.Lock()
|
||||
delete(j.tickets, ticket)
|
||||
j.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
out := *r.out
|
||||
out.Ticket = ticket
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// forget drops the answers nobody collected in time. Called with mu held.
|
||||
func (j *Judge) forget() {
|
||||
now := j.Now()
|
||||
for t, r := range j.tickets {
|
||||
if now.After(r.until) {
|
||||
delete(j.tickets, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// answerOf reads print mode's result: the structured answer, or why there is none.
|
||||
func answerOf(out []byte) (json.RawMessage, error) {
|
||||
var r struct {
|
||||
@@ -213,17 +321,21 @@ func judgeTool(j *Judge) stdio.Tool {
|
||||
"object); optionally the model (" + strings.Join(JudgeModels, ", ") + "; default " + JudgeModels[0] + "). The agent " +
|
||||
"answers as the account agents run as here, under the licence this node is bound to, with no tools, " +
|
||||
"no tool servers, no instructions and no session kept — it can only answer — within 25 seconds or " +
|
||||
"not at all. One at a time on a node. Refused: an empty or larger than 64 KiB question, a schema that " +
|
||||
"not at all, unless within asks longer (at most 120 seconds, for a long question): then, when it is not done in " +
|
||||
"20 seconds, it answers pending with a ticket, and claude_code_judged collects the answer (novox/hq issue 508). " +
|
||||
"One at a time on a node. Refused: an empty or larger than 64 KiB question, a schema that " +
|
||||
"is not an object, an unknown model, a node holding no licence. The question is never logged.",
|
||||
Input: map[string]any{
|
||||
"question": str("the question, with everything the answer needs"),
|
||||
"schema": map[string]any{"type": "object", "description": "the JSON schema the answer follows"},
|
||||
"model": map[string]any{"type": "string", "enum": JudgeModels, "description": "the model (default " + JudgeModels[0] + ")"},
|
||||
"within": map[string]any{"type": "number", "description": "the bound in seconds, at most 120 (default 25); " +
|
||||
"past 25 a judgement not done in 20 seconds answers pending with a ticket for claude_code_judged"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
for k := range a {
|
||||
if k != "question" && k != "schema" && k != "model" {
|
||||
return nil, fmt.Errorf("claude_code_judge takes question, schema and model; not %s", k)
|
||||
if k != "question" && k != "schema" && k != "model" && k != "within" {
|
||||
return nil, fmt.Errorf("claude_code_judge takes question, schema, model and within; not %s", k)
|
||||
}
|
||||
}
|
||||
var ask JudgeAsk
|
||||
@@ -241,6 +353,35 @@ func judgeTool(j *Judge) stdio.Tool {
|
||||
return nil, errors.New("model is text")
|
||||
}
|
||||
}
|
||||
if v, given := a["within"]; given && v != nil {
|
||||
secs, ok := v.(float64)
|
||||
if !ok || secs <= 0 {
|
||||
return nil, errors.New("within is a number of seconds")
|
||||
}
|
||||
ask.Within = time.Duration(secs * float64(time.Second))
|
||||
}
|
||||
return j.Judge(ask)
|
||||
}}
|
||||
}
|
||||
|
||||
// judgedTool collects a judgement asked with a bound past one bus call's (novox/hq issue 508).
|
||||
func judgedTool(j *Judge) stdio.Tool {
|
||||
return stdio.Tool{Name: "claude_code_judged",
|
||||
Description: "The answer of a judgement claude_code_judge answered pending with a ticket (novox/hq issue 508): " +
|
||||
"waits up to 20 seconds, then answers the judgement's answer, why it failed, or pending again. A ticket is " +
|
||||
"answered once; one nobody collects is dropped ten minutes after its bound, and none survives a restart of " +
|
||||
"this module.",
|
||||
Input: map[string]any{"ticket": str("the ticket claude_code_judge answered")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
for k := range a {
|
||||
if k != "ticket" {
|
||||
return nil, fmt.Errorf("claude_code_judged takes ticket; not %s", k)
|
||||
}
|
||||
}
|
||||
ticket, ok := a["ticket"].(string)
|
||||
if !ok || ticket == "" {
|
||||
return nil, errors.New("claude_code_judged needs ticket, as claude_code_judge answered it")
|
||||
}
|
||||
return j.Collect(ticket)
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -166,3 +166,102 @@ func TestTheToolTakesOnlyItsArguments(t *testing.T) {
|
||||
t.Fatalf("%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A long question may ask a longer bound than one bus call allows (novox/hq issue 508): the call answers a ticket
|
||||
// when the judgement is not done within the call's wait, the judgement runs on to its own bound, and
|
||||
// claude_code_judged collects its answer.
|
||||
func TestALongJudgementIsCollectedByItsTicket(t *testing.T) {
|
||||
var deadline time.Time
|
||||
j := testJudge(true, true, func(ctx context.Context, _ []string, _ []byte) ([]byte, error) {
|
||||
deadline, _ = ctx.Deadline()
|
||||
select {
|
||||
case <-time.After(400 * time.Millisecond):
|
||||
return []byte(`{"structured_output":{"verdict":"none"}}`), nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
})
|
||||
j.Wait = 50 * time.Millisecond
|
||||
start := time.Now()
|
||||
out, err := j.Judge(JudgeAsk{Question: "a long record", Schema: verdictSchema, Within: 90 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.Pending || out.Ticket == "" || len(out.Answer) != 0 || out.Node != "control" {
|
||||
t.Fatalf("not a ticket: %+v", out)
|
||||
}
|
||||
var got *Judged
|
||||
for i := 0; i < 100; i++ {
|
||||
got, err = j.Collect(out.Ticket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Pending {
|
||||
break
|
||||
}
|
||||
}
|
||||
if got.Pending || string(got.Answer) != `{"verdict":"none"}` || got.Ticket != out.Ticket {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
// Read once the judgement is collected: its end is what orders the agent's write before this read.
|
||||
if left := deadline.Sub(start); left < 80*time.Second || left > 91*time.Second {
|
||||
t.Fatalf("the judgement's bound is %s, not the 90s asked", left)
|
||||
}
|
||||
if _, err := j.Collect(out.Ticket); err == nil || !strings.Contains(err.Error(), "no judgement") {
|
||||
t.Fatalf("a ticket collected twice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A long judgement that fails says why when it is collected, and its bound is never past MostJudgeBound.
|
||||
func TestALongJudgementIsBoundedAndSaysWhyItFailed(t *testing.T) {
|
||||
j := testJudge(true, true, func(ctx context.Context, _ []string, _ []byte) ([]byte, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
})
|
||||
if _, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema, Within: MostJudgeBound + time.Second}); err == nil ||
|
||||
!strings.Contains(err.Error(), "at most") {
|
||||
t.Fatalf("a bound past the most taken: %v", err)
|
||||
}
|
||||
j.Wait = 20 * time.Millisecond
|
||||
j.Bound = 30 * time.Millisecond // the test's stand-in for JudgeBound: within is past it, so a ticket
|
||||
out, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema, Within: 200 * time.Millisecond})
|
||||
if err != nil || !out.Pending {
|
||||
t.Fatalf("%+v %v", out, err)
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
got, err := j.Collect(out.Ticket)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "did not answer within") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !got.Pending {
|
||||
t.Fatalf("answered: %+v", got)
|
||||
}
|
||||
}
|
||||
t.Fatal("never ended")
|
||||
}
|
||||
|
||||
// The tools take within and ticket, and nothing else.
|
||||
func TestTheToolsTakeWithinAndTicket(t *testing.T) {
|
||||
j := testJudge(true, true, func(context.Context, []string, []byte) ([]byte, error) {
|
||||
return []byte(`{"structured_output":{"verdict":"none"}}`), nil
|
||||
})
|
||||
out, err := judgeTool(j).Run(map[string]any{"question": "q", "schema": verdictSchema, "within": float64(60)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out.(*Judged); got.Pending || string(got.Answer) != `{"verdict":"none"}` {
|
||||
t.Fatalf("a quick long judgement is answered in its call: %+v", got)
|
||||
}
|
||||
if _, err := judgeTool(j).Run(map[string]any{"question": "q", "schema": verdictSchema, "within": "long"}); err == nil {
|
||||
t.Fatal("within as text was taken")
|
||||
}
|
||||
if _, err := judgedTool(j).Run(map[string]any{"ticket": "nope"}); err == nil || !strings.Contains(err.Error(), "no judgement") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if _, err := judgedTool(j).Run(map[string]any{"ticket": "x", "by": "y"}); err == nil {
|
||||
t.Fatal("an unknown argument was taken")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +355,8 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
||||
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
||||
out = append(out, proposalTools(p)...)
|
||||
out = append(out, judgeTool(NewJudge(p)))
|
||||
judge := NewJudge(p)
|
||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||
return append(out, configTools(p, config, configView)...)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
"claude_code_home_remove",
|
||||
"claude_code_home_removed",
|
||||
"claude_code_home_restore",
|
||||
"claude_code_judge"
|
||||
"claude_code_judge",
|
||||
"claude_code_judged"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
|
||||
Reference in New Issue
Block a user